donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
960 lines (941 loc) • 42.5 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.getFlowAsPlaywrightScript = getFlowAsPlaywrightScript;
exports.getFlowAsAiPlaywrightScript = getFlowAsAiPlaywrightScript;
exports.generateProject = generateProject;
exports.buildCacheContents = buildCacheContents;
exports.convertProposedToolCallToPlaywrightCode = convertProposedToolCallToPlaywrightCode;
exports.prettifyCode = prettifyCode;
const node_path_1 = __importDefault(require("node:path"));
const fs = __importStar(require("fs"));
const prettier_1 = require("prettier");
const cache_1 = require("../lib/ai/cache/cache");
const cacheEntryBuilder_1 = require("../lib/ai/cache/cacheEntryBuilder");
const cacheLocator_1 = require("../lib/ai/cache/cacheLocator");
const DonobuFlowsManager_1 = require("../managers/DonobuFlowsManager");
const GptConfig_1 = require("../models/GptConfig");
const AnalyzePageTextTool_1 = require("../tools/AnalyzePageTextTool");
const AssertPageTool_1 = require("../tools/AssertPageTool");
const AssertTool_1 = require("../tools/AssertTool");
const ChangeWebBrowserTabTool_1 = require("../tools/ChangeWebBrowserTabTool");
const ChooseSelectOptionTool_1 = require("../tools/ChooseSelectOptionTool");
const ClickTool_1 = require("../tools/ClickTool");
const CreateBrowserCookieReportTool_1 = require("../tools/CreateBrowserCookieReportTool");
const DoubleClickTool_1 = require("../tools/DoubleClickTool");
const GoForwardOrBackTool_1 = require("../tools/GoForwardOrBackTool");
const GoToWebpageTool_1 = require("../tools/GoToWebpageTool");
const HoverOverElementTool_1 = require("../tools/HoverOverElementTool");
const InputFakerTool_1 = require("../tools/InputFakerTool");
const InputRandomizedEmailAddressTool_1 = require("../tools/InputRandomizedEmailAddressTool");
const InputTextTool_1 = require("../tools/InputTextTool");
const MakeCommentTool_1 = require("../tools/MakeCommentTool");
const MarkObjectiveCompleteTool_1 = require("../tools/MarkObjectiveCompleteTool");
const MarkObjectiveNotCompletableTool_1 = require("../tools/MarkObjectiveNotCompletableTool");
const PressKeyTool_1 = require("../tools/PressKeyTool");
const ReloadPageTool_1 = require("../tools/ReloadPageTool");
const RememberPageTextTool_1 = require("../tools/RememberPageTextTool");
const RunAccessibilityTestTool_1 = require("../tools/RunAccessibilityTestTool");
const ScrollPageTool_1 = require("../tools/ScrollPageTool");
const SummarizeLearningsTool_1 = require("../tools/SummarizeLearningsTool");
const WaitTool_1 = require("../tools/WaitTool");
const JsonUtils_1 = require("../utils/JsonUtils");
const MiscUtils_1 = require("../utils/MiscUtils");
function getLocalPlaywrightVersion() {
const pkgPath = require.resolve('playwright/package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return pkgJson.version;
}
const PLAYWRIGHT_VERSION = getLocalPlaywrightVersion();
/**
* Computes the `allowedTools` and `maxToolCalls` values as they will appear at
* runtime when the generated test code is executed by `PageAi.buildDescriptor`.
*
* The generated test code only emits an explicit `allowedTools` option when the
* flow uses non-default tools, and omits `maxToolCalls` when it equals the
* default. At runtime the omitted values fall back to `[]` and
* `DEFAULT_MAX_TOOL_CALLS` respectively. The cache lock file must store these
* same resolved values so that the cache key matches on the first run.
*/
function computeRuntimeCacheKeyFields(toolCalls, defaultToolNames, minimalToolNames, maxToolCalls) {
const toolNamesFromCalls = Array.from(new Set(toolCalls.map((tc) => tc.name)))
.filter((name) => !minimalToolNames.has(name))
.sort();
const usesNonDefaultTool = toolNamesFromCalls.some((name) => !defaultToolNames.has(name));
// Mirror the runtime defaulting in PageAi.buildDescriptor:
// options?.allowedTools ?? []
const resolvedAllowedTools = usesNonDefaultTool && toolNamesFromCalls.length > 0
? toolNamesFromCalls
: [];
// Mirror: options?.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS
const resolvedMaxToolCalls = maxToolCalls !== null &&
maxToolCalls !== DonobuFlowsManager_1.DonobuFlowsManager.DEFAULT_MAX_TOOL_CALLS
? maxToolCalls
: DonobuFlowsManager_1.DonobuFlowsManager.DEFAULT_MAX_TOOL_CALLS;
return {
allowedTools: resolvedAllowedTools,
maxToolCalls: resolvedMaxToolCalls,
};
}
/** Creates a Node.js Microsoft Playwright script to replay the given flow. */
async function getFlowAsPlaywrightScript(flowMetadata, toolCalls, options = {}, toolRegistry) {
// These tools are not supported in the generated script because they have
// static outputs that have no side effects, so they are not actually
// doing anything.
const unsupportedToolsByName = new Set([
MakeCommentTool_1.MakeCommentTool.NAME,
MarkObjectiveCompleteTool_1.MarkObjectiveCompleteTool.NAME,
MarkObjectiveNotCompletableTool_1.MarkObjectiveNotCompletableTool.NAME,
SummarizeLearningsTool_1.SummarizeLearningsTool.NAME,
]);
const envVarsAnnotations = (flowMetadata.envVars ?? []).map((env) => {
return {
type: 'ENV',
description: env,
};
});
const allTools = toolRegistry.allTools();
const gptClientRequired = isGptClientRequired(flowMetadata, toolCalls, allTools);
const gptSetupNote = gptClientRequired
? `* Note that this test uses tools that require the usage of an LLM, so be
* sure to have an appropriate LLM API key available. This can be done
* by providing an environment variable (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY,
* or GOOGLE_GENERATIVE_AI_API_KEY) when running the test...
*
* Example: \`OPENAI_API_KEY=YOUR_KEY npx playwright test\`
*
* ...or by configuring a flow runner using the Donobu app.
`
: ' ';
const hasObjective = (flowMetadata.overallObjective?.trim().length ?? 0) > 0;
const testDetails = hasObjective
? [
{
type: 'objective',
description: `${sanitizeForTemplateLiteral(flowMetadata.overallObjective ?? '')}`,
},
...envVarsAnnotations,
]
: envVarsAnnotations;
const annotationsFromOptions = options?.flowAnnotations?.[flowMetadata.id] ?? [];
const combinedAnnotations = (testDetails?.length ?? 0) + annotationsFromOptions.length > 0
? [...(testDetails ?? []), ...annotationsFromOptions]
: [];
const testName = flowMetadata.name
? flowMetadata.name
// Escape backslashes first.
.replace(/\\/g, '\\\\')
// Escape single quotes.
.replace(/'/g, "\\'")
// Escape newlines.
.replace(/\n/g, '\\n')
// Escape carriage returns.
.replace(/\r/g, '\\r')
: `Test for ${flowMetadata.web?.targetWebsite ?? 'flow'}`;
const scriptedToolCalls = toolCalls
.filter((toolCall) => !unsupportedToolsByName.has(toolCall.name))
.map((toolCall) => {
return convertProposedToolCallToPlaywrightCode(toolCall);
})
.join('\n\n');
const annotationsLiteral = combinedAnnotations.length > 0
? JSON.stringify({ annotation: combinedAnnotations }, null, 2)
: null;
const needsTestInfo = !!flowMetadata.resultJsonSchema;
const resultJson = flowMetadata.resultJsonSchema
? `// Extract an object from the page using the following JSON-schema.
const extractedObject = await page.ai.extract(
jsonSchemaToZod(${JSON.stringify(flowMetadata.resultJsonSchema)})
);
testInfo.attach('extracted-object', { body: JSON.stringify(extractedObject), contentType: 'application/json' });`
: '';
const needsExpectImport = toolCalls.some((toolCall) => toolCall.name === AssertPageTool_1.AssertPageTool.NAME);
const needsJsonSchemaToZodImport = flowMetadata.resultJsonSchema;
const preamble = gptSetupNote.trim().length > 0
? `/**
${gptSetupNote}*/`
: '';
const script = `${preamble}
import { test${needsExpectImport ? ', expect' : ''}${needsJsonSchemaToZodImport ? ', jsonSchemaToZod' : ''} } from 'donobu';
${annotationsLiteral ? 'const testDetails = ' + annotationsLiteral + ';' : ''}
test(${JSON.stringify(testName)}${annotationsLiteral ? ', testDetails' : ''},
async ({ page }${needsTestInfo ? ', testInfo' : ''}) => {
${scriptedToolCalls}
${resultJson}
});
`;
return prettifyCode(script);
}
/** Creates a Node.js Microsoft Playwright script to replay the given flow. */
async function getFlowAsAiPlaywrightScript(flowMetadata, toolCalls, options, toolRegistry) {
const [firstToolCall, ...remaingToolCalls] = toolCalls;
// If the first tool call is "GoToWebpage", then we peel it off and treat it
// specially.
const specialCaseGoto = firstToolCall.name === GoToWebpageTool_1.GoToWebpageTool.NAME && remaingToolCalls.length > 0;
const cachePath = (0, cacheLocator_1.relativePageAiCachePathForSource)(node_path_1.default.join('tests', getTestFileName(flowMetadata)));
const gptSetupNote = ` * This test replays a recorded Donobu flow via \`page.ai(...)\` using the cached
* tool calls stored for this spec in \`${cachePath}\`.
* If the cache entry is missing or the parameters change, the run falls back
* to autonomous mode and will require a GPT API key (e.g. DONOBU_API_KEY,
* OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY).
`;
const testName = flowMetadata.name
? flowMetadata.name
// Escape backslashes first.
.replace(/\\/g, '\\\\')
// Escape single quotes.
.replace(/'/g, "\\'")
// Escape newlines.
.replace(/\n/g, '\\n')
// Escape carriage returns.
.replace(/\r/g, '\\r')
: `Test for ${flowMetadata.web?.targetWebsite ?? 'flow'}`;
const instructionSource = flowMetadata.overallObjective?.trim()
? flowMetadata.overallObjective
: `Replay the recorded flow for ${flowMetadata.web?.targetWebsite ?? 'flow'}`;
const sanitizedInstruction = sanitizeForTemplateLiteral(instructionSource);
const annotations = [
...(options?.flowAnnotations?.[flowMetadata.id] ?? []),
];
const annotationsLiteral = annotations.length > 0
? JSON.stringify({ annotation: annotations }, null, 2)
: null;
const defaultToolNames = new Set(toolRegistry.defaultTools().map((tool) => tool.name));
const minimalToolNames = new Set(toolRegistry.minimalTools().map((t) => t.name));
const effectiveToolCalls = specialCaseGoto ? remaingToolCalls : toolCalls;
const runtimeFields = computeRuntimeCacheKeyFields(effectiveToolCalls, defaultToolNames, minimalToolNames, flowMetadata.maxToolCalls);
const donobuImports = ['test'];
if (flowMetadata.resultJsonSchema !== null) {
donobuImports.push('jsonSchemaToZod');
}
const optionsLines = [];
if (flowMetadata.resultJsonSchema) {
optionsLines.push(`schema: jsonSchemaToZod(${JSON.stringify(flowMetadata.resultJsonSchema)})`);
}
if (runtimeFields.allowedTools.length > 0) {
optionsLines.push(`allowedTools: ${JSON.stringify(runtimeFields.allowedTools)}`);
}
if (runtimeFields.maxToolCalls !== DonobuFlowsManager_1.DonobuFlowsManager.DEFAULT_MAX_TOOL_CALLS) {
optionsLines.push(`maxToolCalls: ${runtimeFields.maxToolCalls}`);
}
if (flowMetadata.envVars && flowMetadata.envVars.length > 0) {
optionsLines.push(`envVars: ${JSON.stringify(flowMetadata.envVars)}`);
}
if (options?.areElementIdsVolatile) {
optionsLines.push(`volatileElementIds: true`);
}
if (options?.disableSelectorFailover) {
optionsLines.push(`noSelectorFailover: true`);
}
const aiOptionsLiteral = optionsLines.length > 0 ? `{${optionsLines.join(',')}}` : '';
const aiCallExpression = optionsLines.length > 0
? `page.ai(\`${sanitizedInstruction}\`, ${aiOptionsLiteral})`
: `page.ai(\`${sanitizedInstruction}\`)`;
const needsTestInfo = flowMetadata.resultJsonSchema !== null;
const aiInvocation = needsTestInfo
? `const extractedObj = await ${aiCallExpression};
await testInfo.attach('extracted-object', { body: JSON.stringify(extractedObj, null, 2), contentType: 'application/json' });`
: `await ${aiCallExpression};`;
const gotoWebpage = specialCaseGoto
? `await page.goto(${JSON.stringify(firstToolCall.parameters.url)});`
: '';
const preamble = `/**
${gptSetupNote}*/`;
const script = `${preamble}
import { ${donobuImports.join(',')} } from 'donobu';
${annotationsLiteral ? 'const testDetails = ' + annotationsLiteral + ';' : ''}
test('${testName}'${annotationsLiteral ? ', testDetails' : ''}, async ({ page }${needsTestInfo ? ', testInfo' : ''}) => {
${gotoWebpage}
${aiInvocation}
});
`;
return prettifyCode(script);
}
/**
* Generates a complete Playwright project structure.
* Browser state dependencies are materialized as static JSON files
* and referenced via storageState paths in the Playwright config.
*/
async function generateProject(flowsWithToolCalls, resolvedBrowserStates, gptConfig, options, toolRegistry) {
const flowsMetadata = flowsWithToolCalls.map((f) => f.metadata);
const files = [];
// Generate browser state JSON files and build a path map for the config.
const storageStatePaths = new Map();
const browserStateEntries = [];
for (const flow of flowsMetadata) {
const browserState = resolvedBrowserStates.get(flow.id);
if (browserState) {
const fileName = `${getProjectName(flow)}.json`;
const filePath = `browser-states/${fileName}`;
files.push({
path: filePath,
content: JSON.stringify(browserState, null, 2),
});
storageStatePaths.set(flow.id, filePath);
browserStateEntries.push({ flowId: flow.id, filePath });
}
}
const hasBrowserStates = browserStateEntries.length > 0;
// Generate individual test files
const testFiles = await generateTestFiles(flowsWithToolCalls, options, toolRegistry);
files.push(...testFiles);
// Generate playwright.config.ts
files.push({
path: 'playwright.config.ts',
content: await generatePlaywrightConfig(flowsMetadata, storageStatePaths, options),
});
files.push({
path: '.github/workflows/run-tests.yml',
content: await generateGitHubActionsWorkflow(flowsWithToolCalls, gptConfig, options, hasBrowserStates, toolRegistry),
});
// Generate package.json if needed
files.push({
path: 'package.json',
content: generatePackageJson(options),
});
// Generate README
files.push({
path: 'README.md',
content: generateReadme(hasBrowserStates),
});
files.push({
path: '.gitignore',
content: generateGitIgnore(),
});
const cacheFiles = options.playwrightScriptVariant === 'ai'
? await buildCacheFiles(flowsWithToolCalls, toolRegistry)
: [];
files.push(...cacheFiles);
return { files };
}
async function generateGitHubActionsWorkflow(flowsWithToolCalls, gptConfig, options, hasBrowserStates = false, toolRegistry) {
const flowsMetadata = flowsWithToolCalls.map((f) => f.metadata);
const allUniqueEnvVars = [
...new Set(flowsMetadata.flatMap((flow) => flow.envVars || [])),
];
const allTools = toolRegistry.allTools();
const gptClientRequired = flowsWithToolCalls.some((f) => {
return isGptClientRequired(f.metadata, f.toolCalls, allTools);
}) || !options.disableSelfHealingTests;
const envVarsList = [];
// The old version of self-healing uses an environment variable instead of
// a command-line argument to denote if tests should self-heal or not.
if (!options.disableSelfHealingTests &&
options.playwrightScriptVariant === 'classic') {
envVarsList.push('SELF_HEAL_TESTS_ENABLED: true');
}
if (gptClientRequired) {
const defaultGptSetup = [
'# Uncomment the desired GPT provider and set up the secret in GitHub.',
'# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}',
'# GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}',
'# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}',
];
// safeParse gives us proper type narrowing for built-in config types.
// Plugin-provided types fall through to the default/generic setup.
const builtIn = gptConfig
? GptConfig_1.GptConfigSchema.safeParse(gptConfig)
: undefined;
if (builtIn?.success) {
switch (builtIn.data.type) {
case 'ANTHROPIC':
envVarsList.push('ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}', `ANTHROPIC_MODEL_NAME: "${builtIn.data.modelName}"`);
break;
case 'DONOBU':
envVarsList.push('DONOBU_API_KEY: ${{ secrets.DONOBU_API_KEY }}');
break;
case 'GOOGLE_GEMINI':
envVarsList.push('GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}', `GOOGLE_GENERATIVE_AI_MODEL_NAME: "${builtIn.data.modelName}"`);
break;
case 'OPENAI':
envVarsList.push('OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}', `OPENAI_API_MODEL_NAME: "${builtIn.data.modelName}"`);
break;
default:
envVarsList.push(...defaultGptSetup);
}
}
else {
envVarsList.push(...defaultGptSetup);
}
}
// When browser states need to be fetched from Donobu Cloud in CI,
// ensure DONOBU_API_KEY is included (unless already added for GPT).
if (hasBrowserStates &&
!envVarsList.some((v) => v.startsWith('DONOBU_API_KEY:'))) {
envVarsList.push('DONOBU_API_KEY: ${{ secrets.DONOBU_API_KEY }}');
}
envVarsList.push(...allUniqueEnvVars.map((envVarName) => `${envVarName}: \${{ secrets.${envVarName} }}`));
// Let the Donobu Slack reporter POST directly when a webhook secret is
// present, and link each message back to the workflow run.
envVarsList.push('DONOBU_SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}', 'DONOBU_REPORT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}');
const envVarsSection = envVarsList.length > 0
? `\n env:\n ${envVarsList.join('\n ')}`
: '';
const triggerSection = `
on:
pull_request:
workflow_dispatch:`;
const xvfbStep = options.runInHeadedMode
? `
- name: Install XVFB for headed mode
run: sudo apt-get update && sudo apt-get install -y xvfb
`
: '';
// The latest version of self-healing uses command-line arguments instead of
// an environment variable to denote if tests should self-heal or not.
const selfHealingSuffix = !options.disableSelfHealingTests && options.playwrightScriptVariant === 'ai'
? ' --auto-heal'
: '';
const testCommand = options.runInHeadedMode
? `xvfb-run -a npx donobu test${selfHealingSuffix}`
: `npx donobu test${selfHealingSuffix}`;
const pullRequestCreationSection = options.disablePullRequestCreation
? ''
: `
# Open a self-healing PR with whatever auto-heal changed. Donobu writes
# the PR body to test-results/auto-heal-pr-body.md when it heals tests;
# the hashFiles() guard skips PR creation when no body is present.
- name: Create Pull Request for Test Fixes (if any)
if: \${{ github.event_name != 'pull_request' && hashFiles('test-results/auto-heal-pr-body.md') }}
uses: peter-evans/create-pull-request@v8
with:
token: \${{ secrets.GITHUB_TOKEN }}
commit-message: "Donobu auto-heal: fix failing tests"
title: "Donobu auto-heal: Test fixes for \${{ github.ref_name }}"
body-path: test-results/auto-heal-pr-body.md
branch: fix-playwright-tests-for-\${{ github.ref_name }}
base: \${{ github.ref_name }}
labels: donobu, auto-heal
delete-branch: true`;
return `name: Run Playwright Tests
${triggerSection}
jobs:
run-donobu-flows:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
- name: Install Dependencies
run: npm ci && npx playwright install --with-deps
${xvfbStep}
- name: Run Playwright Tests
id: run_tests
continue-on-error: true${envVarsSection}
run: ${testCommand}
- name: Save Test Reports as Artifacts
uses: actions/upload-artifact@v6
if: always()
with:
name: test-results
path: test-results/
retention-days: 3
${pullRequestCreationSection}`;
}
async function buildCacheContents(flowsWithToolCalls, toolRegistry) {
const defaultToolNames = new Set(toolRegistry.defaultTools().map((tool) => tool.name));
const minimalToolNames = new Set(toolRegistry.minimalTools().map((t) => t.name));
const entries = flowsWithToolCalls
// We can only create page.ai caches for flows that have an objective.
.filter(({ metadata }) => metadata.overallObjective?.trim() && metadata.runMode === 'AUTONOMOUS')
.map(({ metadata, toolCalls }) => {
const [firstToolCall, ...remaingToolCalls] = toolCalls;
// If the first tool call is "GoToWebpage", then we peel it off and treat it
// specially (i.e. it will be an explicit tool call in the generated test file).
const specialCaseGoto = firstToolCall?.name === GoToWebpageTool_1.GoToWebpageTool.NAME &&
remaingToolCalls.length > 0;
const toolCallsForCache = specialCaseGoto ? remaingToolCalls : toolCalls;
// Extract hostname from URL for cache key to allow caching across different
// paths and query parameters on the same domain
let pageUrlForCache;
try {
const url = new URL(metadata.web?.targetWebsite ?? '');
pageUrlForCache = url.hostname;
}
catch {
// Fallback to full URL if parsing fails
pageUrlForCache = metadata.web?.targetWebsite ?? '';
}
const cacheEntry = cacheEntryBuilder_1.PageAiCacheEntryBuilder.fromMetadata(pageUrlForCache, metadata, toolCallsForCache);
// Compute allowedTools and maxToolCalls as the runtime will see them,
// so the cache lock file keys match the keys built by PageAi.buildDescriptor
// when the generated test code is executed.
const runtimeFields = computeRuntimeCacheKeyFields(toolCallsForCache, defaultToolNames, minimalToolNames, metadata.maxToolCalls);
return {
...cacheEntry,
allowedTools: runtimeFields.allowedTools,
maxToolCalls: runtimeFields.maxToolCalls,
schema: cacheEntry.schema === null
? null
: JSON.parse(JSON.stringify(cacheEntry.schema)),
};
});
return {
caches: entries,
};
}
async function buildCacheFiles(flowsWithToolCalls, toolRegistry) {
const files = [];
for (const flow of flowsWithToolCalls) {
const contents = await buildCacheContents([flow], toolRegistry);
if (contents.caches.length === 0) {
continue;
}
const prettifiedJs = await prettifyCode((0, cache_1.renderCacheModule)(contents.caches));
const specFilepath = node_path_1.default.join('tests', getTestFileName(flow.metadata));
const cacheFilepath = (0, cacheLocator_1.buildPageAiCachePath)(specFilepath);
files.push({
path: cacheFilepath,
content: prettifiedJs,
});
}
return files;
}
/**
* Maps a proposed Donobu tool call to valid NodeJS Playwright code that uses
* the `DonobuExtendedPage` extension.
*/
function convertProposedToolCallToPlaywrightCode(proposedToolCall) {
const rawParams = JsonUtils_1.JsonUtils.objectToJson(proposedToolCall.parameters);
const rationale = rawParams.rationale && rawParams.rationale.trim().length > 0
? rawParams.rationale
.split('\n')
.map((line) => `// ${line}`.trim())
.join('\n') + '\n'
: '';
// Delete fields that should not be directly mapped.
delete rawParams.rationale;
delete rawParams.whyThisAnnotation;
delete rawParams.annotation;
const hasNonEmptyParameters = Object.keys(rawParams).length > 0;
const serializedParams = JSON.stringify(rawParams, null, 2);
switch (proposedToolCall.name) {
case AssertTool_1.AssertTool.NAME: {
const options = {
...(rawParams.retries > 0 ? { retries: rawParams.retries } : {}),
...(rawParams.retries > 0 && rawParams.retryWaitSeconds > 0
? { retryDelaySeconds: rawParams.retryWaitSeconds }
: {}),
};
const serializedOptions = Object.keys(options).length > 0
? `,${JSON.stringify(options, null, 2)}`
: '';
return `${rationale}await page.ai.assert(
${JSON.stringify(rawParams.assertionToTestFor)}${serializedOptions});`;
}
case ChangeWebBrowserTabTool_1.ChangeWebBrowserTabTool.NAME: {
const url = ChangeWebBrowserTabTool_1.ChangeWebBrowserTabCoreSchema.parse(proposedToolCall.parameters);
return `${rationale}page = await page.changeTab(${JSON.stringify(url.tabUrl)});`;
}
case ChooseSelectOptionTool_1.ChooseSelectOptionTool.NAME: {
const find = parseFindCall(rawParams);
const { selector: _selector, optionValues } = rawParams;
return `${rationale}${find}.selectOption(${JSON.stringify(optionValues)})`;
}
case ClickTool_1.ClickTool.NAME: {
const find = parseFindCall(rawParams);
const { button } = rawParams;
return `${rationale}${find}.click(${button ? JSON.stringify(button) : ''})`;
}
case DoubleClickTool_1.DoubleClickTool.NAME: {
const find = parseFindCall(rawParams);
return `${rationale}${find}.dblclick()`;
}
case GoForwardOrBackTool_1.GoForwardOrBackTool.NAME: {
if (rawParams.direction === 'FORWARD') {
return `${rationale}await page.goForward();`;
}
else if (rawParams.direction === 'BACK') {
return `${rationale}await page.goBack();`;
}
else {
throw new Error(`Invalid ${GoForwardOrBackTool_1.GoForwardOrBackTool.NAME} params: ${serializedParams}`);
}
}
case GoToWebpageTool_1.GoToWebpageTool.NAME: {
return `${rationale}await page.goto(${JSON.stringify(rawParams.url)});`;
}
case HoverOverElementTool_1.HoverOverElementTool.NAME: {
const find = parseFindCall(rawParams);
return `${rationale}${find}.hover()`;
}
case InputRandomizedEmailAddressTool_1.InputRandomizedEmailAddressTool.NAME: {
const find = parseFindCall(rawParams);
const { selector: _selector, baseEmail, finalizeWithSubmit: submit, } = rawParams;
return `${rationale}${find}.inputRandomizedEmailAddress(
${JSON.stringify(baseEmail)},
${submit ? JSON.stringify({ submit }) : ''})`;
}
case InputFakerTool_1.InputFakerTool.NAME: {
const find = parseFindCall(rawParams);
const { selector: _selector, dataType, append, finalizeWithSubmit: submit, } = rawParams;
const options = append === true || submit
? JSON.stringify({
...(append === true ? { append } : {}),
...(submit ? { submit } : {}),
})
: '';
return `${rationale}${find}.inputFaker(
${JSON.stringify(dataType)},
${options})`;
}
case InputTextTool_1.InputTextTool.NAME: {
const find = parseFindCall(rawParams);
const { selector: _selector, text, append, finalizeWithSubmit: submit, } = rawParams;
const options = append === true || submit
? JSON.stringify({
...(append === true ? { append } : {}),
...(submit ? { submit } : {}),
})
: '';
return `${rationale}${find}.inputText(
${JSON.stringify(text)},
${options})`;
}
case PressKeyTool_1.PressKeyTool.NAME: {
const find = parseFindCall(rawParams);
const { selector: _selector, key } = rawParams;
return `${rationale}${find}.pressKey(${JSON.stringify(key)})`;
}
case ReloadPageTool_1.ReloadPageTool.NAME: {
return `${rationale}await page.reload();`;
}
case ScrollPageTool_1.ScrollPageTool.NAME: {
const find = parseFindCall(rawParams);
const { selector: _selector, direction, maxScroll } = rawParams;
return `${rationale}${find}.scroll(
${JSON.stringify(direction)},
${maxScroll ? JSON.stringify({ maxScroll }) : ''})`;
}
case WaitTool_1.WaitTool.NAME: {
return `${rationale}await page.waitForTimeout(${rawParams.seconds * 1000});`;
}
case AssertPageTool_1.AssertPageTool.NAME: {
const assertionType = rawParams.type;
const expected = rawParams.expected;
const isRegex = rawParams.isRegex || false;
switch (assertionType) {
case 'title': {
if (isRegex) {
return `${rationale}await expect(page).toHaveTitle(new RegExp(${JSON.stringify(expected)}));`;
}
else {
return `${rationale}await expect(page).toHaveTitle('${expected}');`;
}
}
case 'url': {
if (isRegex) {
return `${rationale}await expect(page).toHaveURL(new RegExp(${JSON.stringify(expected)}));`;
}
else {
return `${rationale}await expect(page).toHaveURL('${expected}');`;
}
}
case 'content': {
if (isRegex) {
return `${rationale}await expect(page.locator('body')).toContainText(new RegExp(${JSON.stringify(expected)}));`;
}
else {
return `${rationale}await expect(page.getByText('${expected}').first()).toBeVisible();`;
}
}
default: {
// Fallback to the generic tool call if unknown type
return `${rationale}await page.${proposedToolCall.name}(
${hasNonEmptyParameters ? JSON.stringify({ type: assertionType, expected, isRegex }, null, 2) : ''});`;
}
}
}
case RememberPageTextTool_1.RememberPageTextTool.NAME: {
delete rawParams.text;
const updatedSerializedParams = JSON.stringify(rawParams, null, 2);
return `${rationale}await page.run('${proposedToolCall.name}', ${updatedSerializedParams});`;
}
case AnalyzePageTextTool_1.AnalyzePageTextTool.NAME: {
return `${rationale}await page.ai.analyzePageText(
${JSON.stringify(rawParams.analysisToRun)},
${JSON.stringify({ additionalContext: rawParams.additionalRelevantContext })})`;
}
case RunAccessibilityTestTool_1.RunAccessibilityTestTool.NAME: {
return `${rationale}await page.runAccessibilityTest();`;
}
case CreateBrowserCookieReportTool_1.CreateBrowserCookieReportTool.NAME: {
return `${rationale}await page.ai.createCookieReport();`;
}
// All other tools delegate to the general 'run' method.
default: {
const toolName = proposedToolCall.name;
const toolCallScript = hasNonEmptyParameters
? `${rationale}await page.run(${JSON.stringify(toolName)}, ${serializedParams});`
: `${rationale}await page.run(${JSON.stringify(toolName)});`;
return toolCallScript;
}
}
}
function generateGitIgnore() {
return `.DS_Store
.idea
.vscode
node_modules
browser-states
# Test results generated by Playwright
test-results
playwright-report`;
}
/**
* Generates the playwright.config.ts file with storageState paths
*/
async function generatePlaywrightConfig(flows, storageStatePaths, options) {
const projects = [];
for (const flow of flows) {
const projectName = getProjectName(flow);
const storageStatePath = storageStatePaths.get(flow.id);
projects.push(generateProjectConfig(projectName, flow, storageStatePath));
}
const { areElementIdsVolatile, disableSelectorFailover, runInHeadedMode, slowMotionDelay, } = options;
const useConfig = {
screenshot: 'on',
video: 'on',
...(runInHeadedMode && { headless: !runInHeadedMode }),
...(slowMotionDelay &&
slowMotionDelay > 0 && { launchOptions: { slowMo: slowMotionDelay } }),
};
const selfHealingOptions = {
areElementIdsVolatile,
disableSelectorFailover,
};
const metadata = !options.disableSelfHealingTests &&
options.playwrightScriptVariant === 'classic'
? `metadata: ${JSON.stringify({ selfHealingOptions: selfHealingOptions }, null, 2)}`
: '';
const config = `import { defineConfig, devices } from 'donobu';
export default defineConfig({
testDir: './tests',
projects: [ ${projects.join(',')} ],
use: ${JSON.stringify(useConfig, null, 2)},
reporter: [
['donobu/reporter/html'],
['donobu/reporter/markdown'],
// A Slack message will be sent to destination specified by the
// DONOBU_SLACK_WEBHOOK_URL (if it exists) and optionally contain a link
// specified by DONOBU_REPORT_URL.
['donobu/reporter/slack'],
],
${metadata}
});`;
return prettifyCode(config);
}
/**
* Generates a single project configuration
*/
function generateProjectConfig(projectName, flow, storageStatePath) {
const minimumTimeoutMilliseconds = 30000;
const defaultTimeoutMilliseconds = 60000;
const calculatedTimeout = flow.startedAt && flow.completedAt
? (flow.completedAt - flow.startedAt) * 2
: defaultTimeoutMilliseconds;
// Round up to the nearest 10000ms
const timeoutMilliseconds = Math.max(minimumTimeoutMilliseconds, Math.ceil(calculatedTimeout / 10000) * 10000);
const testMatch = `tests/${getTestFileName(flow)}`;
// Get device name from flow config, default to 'Desktop Chromium'
const deviceName = flow.web?.browser?.using?.type === 'device'
? flow.web.browser.using.deviceName || 'Desktop Chromium'
: 'Desktop Chromium';
const storageState = storageStatePath
? `\n storageState: '${storageStatePath}',`
: '';
return `{
name: '${projectName}',
testMatch: '${testMatch}',
use: { ...devices['${deviceName}'],${storageState} },
timeout: ${timeoutMilliseconds}
}`;
}
/**
* Generates test files for all flows
*/
async function generateTestFiles(flowsWithToolCalls, options, toolRegistry) {
const files = [];
const scriptVariant = options.playwrightScriptVariant === 'classic' ? 'classic' : 'ai';
for (const { metadata, toolCalls } of flowsWithToolCalls) {
const fileName = getTestFileName(metadata);
const content = scriptVariant === 'classic' ||
!metadata.overallObjective?.trim() ||
metadata.runMode !== 'AUTONOMOUS'
? await getFlowAsPlaywrightScript(metadata, toolCalls, options, toolRegistry)
: await getFlowAsAiPlaywrightScript(metadata, toolCalls, options, toolRegistry);
files.push({
path: `tests/${fileName}`,
content,
});
}
return files;
}
/**
* Generates package.json
*/
function generatePackageJson(options) {
const selfHealingArg = !options.disableSelfHealingTests && options.playwrightScriptVariant === 'ai'
? ' --auto-heal'
: '';
return JSON.stringify({
name: 'playwright-tests',
version: '1.0.0',
description: 'Playwright-based website tests made with Donobu',
scripts: {
test: `donobu test${selfHealingArg}`,
},
dependencies: {
'@playwright/test': PLAYWRIGHT_VERSION,
donobu: MiscUtils_1.MiscUtils.DONOBU_VERSION,
playwright: PLAYWRIGHT_VERSION,
'playwright-core': PLAYWRIGHT_VERSION,
},
}, null, 2);
}
/**
* Generates README.md
*/
function generateReadme(hasBrowserStates = false) {
const browserStatesSection = hasBrowserStates
? `
## Browser States
Some tests depend on pre-existing browser state (cookies, localStorage, etc.) from previously recorded Donobu flows.
**Local development:** Browser state files are generated in \`browser-states/\` when you create the project from Donobu. They are git-ignored by default.
**CI/CD:** When the local files are missing, the test setup automatically fetches them from Donobu Cloud. Add \`DONOBU_API_KEY\` as a GitHub Actions secret to enable this. Make sure Cloud Sync is enabled in Donobu Studio settings so your flow data is available in the cloud.
`
: '';
return `# Playwright Tests
This project contains [Playwright](https://playwright.dev/)-based tests made with [Donobu](https://www.donobu.com/).
## Installation
Install project dependencies:
\`\`\`bash
npm install
\`\`\`
Install Playwright tooling (e.g. the web browsers for running tests)
\`\`\`bash
npx playwright install
\`\`\`
## Running Tests
\`\`\`bash
npm test
\`\`\`
${browserStatesSection}`;
}
/**
* Gets a project name for a flow
*/
function getProjectName(flow) {
if (flow.name) {
return flow.name
.trim()
.replace(/[^a-zA-Z0-9-_]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
}
else {
return `flow-${flow.id.substring(0, 8)}`;
}
}
/**
* Gets a test file name for a flow
*/
function getTestFileName(flow) {
const projectName = getProjectName(flow);
return `${projectName}.spec.ts`;
}
async function prettifyCode(code) {
const formattedCode = (0, prettier_1.format)(code, {
parser: 'typescript',
semi: true,
singleQuote: true,
});
return formattedCode;
}
/**
* Sanitizes a JSON string to be safely used within a template literal (backtick string)
* Prevents both backtick termination and string interpolation from triggering
*
* @param jsonString - The JSON string to sanitize
* @returns The sanitized string that can be safely used within backticks
*/
function sanitizeForTemplateLiteral(jsonString) {
return (jsonString
// Escape backticks to prevent template literal termination
.replace(/`/g, '\\`')
// Escape ${...} patterns to prevent string interpolation
.replace(/\${/g, '\\${'));
}
function isGptClientRequired(metadata, toolCalls, allTools) {
return (metadata.resultJsonSchema !== null ||
toolCalls
.map((toolCall) => {
return toolCall.name;
})
.map((toolCallName) => {
return allTools.find((t) => t.name === toolCallName);
})
.filter((tool) => {
return tool?.requiresGpt;
}).length > 0);
}
/**
* For tools that have a 'selector' argument, this function will serialize it
* to an appropriate call to the DonobuExtendedPage#find function.
*/
function parseFindCall(args) {
const selector = args.selector;
const [primarySelector, ...failover] = selector.element;
const frame = selector.frame;
const findOptions = {};
if (failover.length > 0) {
findOptions.failover = failover;
}
if (frame) {
findOptions.frame = frame;
}
// Build the page.find(...) call with 1 or 2 args depending on options presence.
const hasOptions = Object.keys(findOptions).length > 0;
const findCall = hasOptions
? `await page.find(${JSON.stringify(primarySelector)}, ${JSON.stringify(findOptions)})`
: `await page.find(${JSON.stringify(primarySelector)})`;
return findCall;
}
//# sourceMappingURL=CodeGenerator.js.map