UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

590 lines (589 loc) 24.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.InMemoryPageAiCache = exports.FilePageAiCache = exports.renderCacheModule = void 0; const node_module_1 = require("node:module"); const node_path_1 = __importDefault(require("node:path")); const node_vm_1 = require("node:vm"); const promises_1 = __importDefault(require("fs/promises")); const LockFile = __importStar(require("proper-lockfile")); const CodeGenerator_1 = require("../../../codegen/CodeGenerator"); const assertCache_1 = require("./assertCache"); const areCacheCollectionsEqual = (left, right) => // We serialize to JSON rather than deep-walking to keep the comparison short // and avoid surprises from prototype differences. JSON.stringify(left) === JSON.stringify(right); // Comparison helper that keeps cache key equality stable even when the schema // or tool list objects are different references but deeply identical. const pageAiCacheKeysMatch = (left, right) => (left.deviceType ?? 'web') === (right.deviceType ?? 'web') && left.pageUrl === right.pageUrl && left.instruction === right.instruction && areCacheCollectionsEqual(left.schema, right.schema) && areCacheCollectionsEqual(left.allowedTools, right.allowedTools) && left.maxToolCalls === right.maxToolCalls; /** * Turns a cached stringified function into an executable runner. We reuse a * fresh VM context per entry to prevent any globals created by one cached * function from leaking into another. */ const compileRunSource = (runSource) => { try { const runnerContext = (0, node_vm_1.createContext)({}); const script = new node_vm_1.Script(`(${runSource})`); const runner = script.runInContext(runnerContext); if (typeof runner !== 'function') { throw new Error('Cache runner source did not produce a function.'); } return runner; } catch (error) { const evaluationError = new Error(`Failed to evaluate cached runner. Source:\n${runSource}`); if (error instanceof Error) { evaluationError.cause = error; } throw evaluationError; } }; // Hydrates the serialized entry with a callable runner. const materializeEntry = (entry) => ({ ...entry, run: compileRunSource(entry.runSource), }); // Drops the live runner to produce a JSON-safe shape for storage/serialization. const stripRunner = (entry) => ({ ...(entry.deviceType && entry.deviceType !== 'web' ? { deviceType: entry.deviceType } : {}), pageUrl: entry.pageUrl, instruction: entry.instruction, schema: entry.schema === null ? null : JSON.parse(JSON.stringify(entry.schema)), allowedTools: [...entry.allowedTools], maxToolCalls: entry.maxToolCalls, envVars: entry.envVars, runSource: entry.runSource, }); // Returns a defensive copy with the same runner reference so callers cannot // mutate our in-memory cache accidentally. const cloneEntryWithRunner = (entry) => ({ ...stripRunner(entry), run: entry.run, }); // --------------------------------------------------------------------------- // Assert cache helpers // --------------------------------------------------------------------------- const assertCacheKeysMatch = (left, right) => left.pageUrl === right.pageUrl && left.assertion === right.assertion; const materializeAssertEntry = (entry) => ({ ...entry, run: (0, assertCache_1.buildAssertExecutor)(entry.steps), }); const cloneAssertEntry = (entry) => ({ pageUrl: entry.pageUrl, assertion: entry.assertion, steps: entry.steps, run: entry.run, }); // --------------------------------------------------------------------------- // Locate cache helpers // --------------------------------------------------------------------------- const locateCacheKeysMatch = (left, right) => left.pageUrl === right.pageUrl && left.description === right.description; const materializeLocateEntry = (entry) => ({ ...entry, run: (0, assertCache_1.buildLocateExecutor)(entry.result), }); const cloneLocateEntry = (entry) => ({ pageUrl: entry.pageUrl, description: entry.description, result: entry.result, run: entry.run, }); // Serializes a cache entry into inline JS that will be emitted to disk. const serializeEntry = (entry) => { const instructionLiteral = entry.instruction === null ? 'null' : JSON.stringify(entry.instruction); const schemaLiteral = entry.schema === null ? 'null' : JSON.stringify(entry.schema); const allowedToolsLiteral = JSON.stringify(entry.allowedTools ?? []); const maxToolCallsLiteral = entry.maxToolCalls === null ? 'null' : String(entry.maxToolCalls); const parts = []; // Only emit deviceType for non-web entries (backwards compat). if (entry.deviceType && entry.deviceType !== 'web') { parts.push(`deviceType: ${JSON.stringify(entry.deviceType)}`); } parts.push(`pageUrl: ${JSON.stringify(entry.pageUrl)}`, `instruction: ${instructionLiteral}`, `schema: ${schemaLiteral}`, `allowedTools: ${allowedToolsLiteral}`, `maxToolCalls: ${maxToolCallsLiteral}`, `run: ${entry.runSource}`); return `{ ${parts.join(', ')} }`; }; // Emits the cache file as a CommonJS module matching what the runtime loader // expects. This keeps the on-disk format aligned between generator and reader. const renderCacheModule = (entries, assertions, locators) => { const serializedEntries = entries.map(serializeEntry).join(', '); const assertionsPart = assertions && assertions.length > 0 ? `, assertions: [${assertions.map((a) => JSON.stringify({ pageUrl: a.pageUrl, assertion: a.assertion, steps: a.steps })).join(', ')}]` : ''; const locatorsPart = locators && locators.length > 0 ? `, locators: [${locators.map((l) => JSON.stringify({ pageUrl: l.pageUrl, description: l.description, result: l.result })).join(', ')}]` : ''; return `/** * @generated * This file was generated by Donobu; do not edit manually. */ /* eslint-disable */ const { expect } = require('donobu'); module.exports = { caches: [${serializedEntries}]${assertionsPart}${locatorsPart} };`; }; exports.renderCacheModule = renderCacheModule; /** * File-backed implementation used by the Playwright fixture. Protects reads and * writes with an OS-level lock so concurrent processes do not corrupt the file. * * Both page.ai flow caches and assertion caches live in the same file to avoid * a separate file and lock for assertions. */ class FilePageAiCache { constructor(cacheFilepath) { this.cacheFilepath = cacheFilepath; } // --- Page.ai flow cache operations --- async get(key) { return this.withCacheLock(async (state) => { const matchingEntry = state.caches.find((entry) => pageAiCacheKeysMatch(entry, key)); return { state, result: matchingEntry ? cloneEntryWithRunner(matchingEntry) : null, }; }); } async put(entry) { await this.withCacheLock(async (state) => { const existingIndex = state.caches.findIndex((candidate) => pageAiCacheKeysMatch(candidate, entry)); const materializedEntry = materializeEntry(entry); const modifiedCaches = existingIndex === -1 ? [...state.caches, materializedEntry] : [...state.caches]; if (existingIndex !== -1) { modifiedCaches[existingIndex] = materializedEntry; } return { state: { ...state, caches: modifiedCaches }, result: undefined, }; }); } async delete(key) { return this.withCacheLock(async (state) => { const targetIndex = state.caches.findIndex((entry) => pageAiCacheKeysMatch(entry, key)); if (targetIndex === -1) { return { state, result: false }; } return { state: { ...state, caches: [ ...state.caches.slice(0, targetIndex), ...state.caches.slice(targetIndex + 1), ], }, result: true, }; }); } async snapshot() { return this.withCacheLock(async (state) => ({ state, result: { caches: state.caches.map((entry) => stripRunner(entry)), assertions: state.assertions.map((a) => ({ pageUrl: a.pageUrl, assertion: a.assertion, steps: a.steps, })), locators: state.locators.map((l) => ({ pageUrl: l.pageUrl, description: l.description, result: l.result, })), }, })); } // --- Assert cache operations --- async getAssert(key) { return this.withCacheLock(async (state) => { const match = state.assertions.find((e) => assertCacheKeysMatch(e, key)); return { state, result: match ? cloneAssertEntry(match) : null, }; }); } async putAssert(entry) { await this.withCacheLock(async (state) => { const idx = state.assertions.findIndex((e) => assertCacheKeysMatch(e, entry)); const materialized = materializeAssertEntry(entry); const modifiedAssertions = idx === -1 ? [...state.assertions, materialized] : [...state.assertions]; if (idx !== -1) { modifiedAssertions[idx] = materialized; } return { state: { ...state, assertions: modifiedAssertions }, result: undefined, }; }); } async deleteAssert(key) { return this.withCacheLock(async (state) => { const idx = state.assertions.findIndex((e) => assertCacheKeysMatch(e, key)); if (idx === -1) { return { state, result: false }; } return { state: { ...state, assertions: [ ...state.assertions.slice(0, idx), ...state.assertions.slice(idx + 1), ], }, result: true, }; }); } // --- Locate cache operations --- async getLocate(key) { return this.withCacheLock(async (state) => { const match = state.locators.find((e) => locateCacheKeysMatch(e, key)); return { state, result: match ? cloneLocateEntry(match) : null, }; }); } async putLocate(entry) { await this.withCacheLock(async (state) => { const idx = state.locators.findIndex((e) => locateCacheKeysMatch(e, entry)); const materialized = materializeLocateEntry(entry); const modifiedLocators = idx === -1 ? [...state.locators, materialized] : [...state.locators]; if (idx !== -1) { modifiedLocators[idx] = materialized; } return { state: { ...state, locators: modifiedLocators }, result: undefined, }; }); } async deleteLocate(key) { return this.withCacheLock(async (state) => { const idx = state.locators.findIndex((e) => locateCacheKeysMatch(e, key)); if (idx === -1) { return { state, result: false }; } return { state: { ...state, locators: [ ...state.locators.slice(0, idx), ...state.locators.slice(idx + 1), ], }, result: true, }; }); } // --- Internal file operations --- async ensureCacheFileExists() { const cacheDirectory = node_path_1.default.dirname(this.cacheFilepath); await promises_1.default.mkdir(cacheDirectory, { recursive: true }); try { await promises_1.default.writeFile(this.cacheFilepath, (0, exports.renderCacheModule)([]), { encoding: 'utf-8', flag: 'wx', }); } catch (error) { if (error.code !== 'EEXIST') { throw error; } } } async loadCacheModule() { const source = await promises_1.default.readFile(this.cacheFilepath, 'utf-8'); const cacheRequire = (0, node_module_1.createRequire)(this.cacheFilepath); const module = { exports: {} }; const sandbox = (0, node_vm_1.createContext)({ module, exports: module.exports, require: cacheRequire, __filename: this.cacheFilepath, __dirname: node_path_1.default.dirname(this.cacheFilepath), }); const script = new node_vm_1.Script(`(function (exports, require, module, __filename, __dirname) {${source}\n})`, { filename: this.cacheFilepath }); const runner = script.runInContext(sandbox); runner(module.exports, cacheRequire, module, this.cacheFilepath, node_path_1.default.dirname(this.cacheFilepath)); return module.exports; } async readCacheFile() { try { const cacheModule = await this.loadCacheModule(); const exported = (cacheModule.default ?? cacheModule); // Parse page.ai flow caches const rawCaches = Array.isArray(exported.caches) ? exported.caches : []; const caches = rawCaches.map((entry) => { if (typeof entry.run !== 'function') { throw new Error(`Invalid cache entry: run must be a function for ${entry.pageUrl ?? 'unknown page'}.`); } const normalized = { ...(entry.deviceType === 'android' ? { deviceType: 'android' } : entry.deviceType === 'ios' ? { deviceType: 'ios' } : {}), pageUrl: String(entry.pageUrl ?? ''), instruction: entry.instruction === null || entry.instruction === undefined ? null : String(entry.instruction), schema: entry.schema === null || entry.schema === undefined ? null : entry.schema, allowedTools: Array.isArray(entry.allowedTools) ? entry.allowedTools : [], maxToolCalls: typeof entry.maxToolCalls === 'number' || entry.maxToolCalls === null ? entry.maxToolCalls : null, envVars: Array.isArray(entry.envVars) ? entry.envVars : null, run: entry.run, runSource: entry.run.toString(), }; return normalized; }); // Parse assertion caches (pure JSON data — no functions) const rawAssertions = Array.isArray(exported.assertions) ? exported.assertions : []; const assertions = rawAssertions.map((entry) => { const steps = Array.isArray(entry.steps) ? entry.steps : []; return materializeAssertEntry({ pageUrl: String(entry.pageUrl ?? ''), assertion: String(entry.assertion ?? ''), steps, }); }); // Parse locate caches (pure JSON data — no functions) const rawLocators = Array.isArray(exported.locators) ? exported.locators : []; const locators = rawLocators.map((entry) => materializeLocateEntry({ pageUrl: String(entry.pageUrl ?? ''), description: String(entry.description ?? ''), result: entry.result, })); return { caches, assertions, locators }; } catch (error) { if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ENOENT') { return { caches: [], assertions: [], locators: [] }; } throw error; } } async writeCacheFile(state) { const serializedCaches = state.caches.map((entry) => stripRunner(entry)); const serializedAssertions = state.assertions.map((e) => ({ pageUrl: e.pageUrl, assertion: e.assertion, steps: e.steps, })); const serializedLocators = state.locators.map((e) => ({ pageUrl: e.pageUrl, description: e.description, result: e.result, })); const prettified = await (0, CodeGenerator_1.prettifyCode)((0, exports.renderCacheModule)(serializedCaches, serializedAssertions, serializedLocators)); await promises_1.default.writeFile(this.cacheFilepath, prettified, 'utf-8'); } async withCacheLock(operation) { await this.ensureCacheFileExists(); const release = await LockFile.lock(this.cacheFilepath, { retries: { retries: 5, minTimeout: 100, maxTimeout: 1000, }, }); try { const currentState = await this.readCacheFile(); const { state, result } = await operation(currentState); // Write only if caches, assertions, or locators changed if (state.caches !== currentState.caches || state.assertions !== currentState.assertions || state.locators !== currentState.locators) { await this.writeCacheFile(state); } return result; } finally { await release(); } } } exports.FilePageAiCache = FilePageAiCache; /** * Lightweight in-memory cache implementation that stays within the current * process. Useful for unit tests that want deterministic cache behaviour * without touching the filesystem. */ class InMemoryPageAiCache { constructor(initialContents = []) { this.assertions = []; this.locators = []; const entries = 'caches' in initialContents ? initialContents.caches : initialContents; this.cache = entries.map((entry) => materializeEntry(entry)); if ('assertions' in initialContents && initialContents.assertions) { this.assertions = initialContents.assertions.map((a) => materializeAssertEntry(a)); } if ('locators' in initialContents && initialContents.locators) { this.locators = initialContents.locators.map((l) => materializeLocateEntry(l)); } } async get(key) { const matchingEntry = this.cache.find((entry) => pageAiCacheKeysMatch(entry, key)); return matchingEntry ? cloneEntryWithRunner(matchingEntry) : null; } async put(entry) { const materialized = materializeEntry(entry); const existingIndex = this.cache.findIndex((candidate) => pageAiCacheKeysMatch(candidate, entry)); if (existingIndex === -1) { this.cache = [...this.cache, materialized]; return; } const nextCache = [...this.cache]; nextCache[existingIndex] = materialized; this.cache = nextCache; } async delete(key) { const targetIndex = this.cache.findIndex((entry) => pageAiCacheKeysMatch(entry, key)); if (targetIndex === -1) { return false; } this.cache = [ ...this.cache.slice(0, targetIndex), ...this.cache.slice(targetIndex + 1), ]; return true; } async snapshot() { return { caches: this.cache.map((entry) => stripRunner(entry)), assertions: this.assertions.map((a) => ({ pageUrl: a.pageUrl, assertion: a.assertion, steps: a.steps, })), locators: this.locators.map((l) => ({ pageUrl: l.pageUrl, description: l.description, result: l.result, })), }; } // --- Assert cache operations --- async getAssert(key) { const match = this.assertions.find((e) => assertCacheKeysMatch(e, key)); return match ? cloneAssertEntry(match) : null; } async putAssert(entry) { const materialized = materializeAssertEntry(entry); const idx = this.assertions.findIndex((e) => assertCacheKeysMatch(e, entry)); if (idx === -1) { this.assertions = [...this.assertions, materialized]; } else { const next = [...this.assertions]; next[idx] = materialized; this.assertions = next; } } async deleteAssert(key) { const idx = this.assertions.findIndex((e) => assertCacheKeysMatch(e, key)); if (idx === -1) { return false; } this.assertions = [ ...this.assertions.slice(0, idx), ...this.assertions.slice(idx + 1), ]; return true; } // --- Locate cache operations --- async getLocate(key) { const match = this.locators.find((e) => locateCacheKeysMatch(e, key)); return match ? cloneLocateEntry(match) : null; } async putLocate(entry) { const materialized = materializeLocateEntry(entry); const idx = this.locators.findIndex((e) => locateCacheKeysMatch(e, entry)); if (idx === -1) { this.locators = [...this.locators, materialized]; } else { const next = [...this.locators]; next[idx] = materialized; this.locators = next; } } async deleteLocate(key) { const idx = this.locators.findIndex((e) => locateCacheKeysMatch(e, key)); if (idx === -1) { return false; } this.locators = [ ...this.locators.slice(0, idx), ...this.locators.slice(idx + 1), ]; return true; } } exports.InMemoryPageAiCache = InMemoryPageAiCache; //# sourceMappingURL=cache.js.map