donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
590 lines (589 loc) • 24.1 kB
JavaScript
;
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