UNPKG

donobu

Version:

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

960 lines (941 loc) 42.5 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.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