donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
249 lines (248 loc) • 12.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CodeGenerator = void 0;
const AnalyzePageTextTool_1 = require("../tools/AnalyzePageTextTool");
const AssertPageTextTool_1 = require("../tools/AssertPageTextTool");
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 GoForwardOrBackTool_1 = require("../tools/GoForwardOrBackTool");
const GoToWebpageTool_1 = require("../tools/GoToWebpageTool");
const HoverOverElementTool_1 = require("../tools/HoverOverElementTool");
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 RunAccessibilityTestTool_1 = require("../tools/RunAccessibilityTestTool");
const ScrollPageTool_1 = require("../tools/ScrollPageTool");
const SummarizeLearningsTool_1 = require("../tools/SummarizeLearningsTool");
const JsonUtils_1 = require("../utils/JsonUtils");
const ToolManager_1 = require("./ToolManager");
class CodeGenerator {
constructor(gptConfigsManager, agentsManager) {
this.gptConfigsManager = gptConfigsManager;
this.agentsManager = agentsManager;
}
/** Creates a Node.js Microsoft Playwright script to replay the given flow. */
async getFlowAsPlaywrightScript(flowMetadata, toolCalls) {
// 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 isGptClientRequired = flowMetadata.resultJsonSchema ||
toolCalls
.map((toolCall) => {
return toolCall.name;
})
.map((toolCallName) => {
return ToolManager_1.ToolManager.ALL_TOOLS.find((t) => t.name === toolCallName);
})
.filter((tool) => {
return tool?.requiresGpt;
}).length > 0;
let gptConfig = null;
// Check if the flow metadata has a specific GPT config name.
// If it does, try to get it from the GptConfigsManager.
// If it doesn't, try to get the default GPT config from the AgentsManager.
//
// This is done so that we can generate code using the preferred GPT client.
if (isGptClientRequired) {
try {
if (flowMetadata.gptConfigName) {
gptConfig = await this.gptConfigsManager.get(flowMetadata.gptConfigName);
}
}
catch (_error) {
// This is expected if the config does not exist.
}
if (!gptConfig) {
const gptConfigName = await this.agentsManager.get('flow-runner');
if (gptConfigName) {
try {
gptConfig = await this.gptConfigsManager.get(gptConfigName);
}
catch (_error) {
// This is expected if the config does not exist.
}
}
}
}
const gptSetupNote = isGptClientRequired
? ` *
* Also, since this test is using tools that require the usage of an LLM, be
* sure to have an appropriate LLM API key available as an environment variable
* (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, etc).
`
: ' ';
const hasObjective = (flowMetadata.overallObjective?.trim().length ?? 0) > 0;
const testDetails = hasObjective
? `{
annotation: {
type: 'objective',
description: \`${this.sanitizeForTemplateLiteral(flowMetadata.overallObjective)}\`
}
}`
: '{}';
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.targetWebsite}`;
const useStorageState = flowMetadata.browser && flowMetadata.browser.initialState
? `storageState: getBrowserStorageStateFixture(${JSON.stringify(flowMetadata.browser.initialState, null, 2)}),`
: undefined;
let useGptClientImport = undefined;
let useGptClient = undefined;
if (isGptClientRequired) {
if (gptConfig && gptConfig.type === 'ANTHROPIC_AWS_BEDROCK') {
useGptClientImport =
"import { anthropicAwsBedrockClientFixture } from 'donobu';";
useGptClient = `gptClient: anthropicAwsBedrockClientFixture('${gptConfig.modelName}', '${gptConfig.region}'),`;
}
else {
useGptClientImport = "import { gptClientFixture } from 'donobu';";
useGptClient = `gptClient: gptClientFixture(),`;
}
}
let testExtension = '';
if (useStorageState || useGptClient) {
testExtension = `.extend({${useStorageState ?? ''}${useGptClient ?? ''}})`;
}
const scriptedToolCalls = toolCalls
.filter((toolCall) => !unsupportedToolsByName.has(toolCall.name))
.map((toolCall) => {
return CodeGenerator.convertProposedToolCallToPlaywrightCode(toolCall);
})
.join('\n');
const resultJson = flowMetadata.resultJsonSchema
? `// Extract an object from the page using the following JSON-schema.
const extractedObject = await page.extract({schema: ${JSON.stringify(flowMetadata.resultJsonSchema)}});
testInfo.attach('extracted-object', { body: JSON.stringify(extractedObject), contentType: 'application/json' });`
: '';
const script = `/**
* Be sure that Donobu is installed before running this script...
* 'npm install donobu' or 'yarn add donobu'
*
* Also, be sure that Playwright's browsers are installed...
* 'npx playwright install'
${gptSetupNote}*/
import { test } from 'donobu';${useGptClientImport ? '\n' + useGptClientImport : ''}${useStorageState ? "\nimport { getBrowserStorageStateFixture } from 'donobu';" : ''}
const testTitle = '${testName}';
const testDetails = ${testDetails};
test${testExtension}(testTitle, testDetails, async ({ page }${flowMetadata.resultJsonSchema ? ', testInfo' : ''}) => {
${scriptedToolCalls}
${resultJson}
});
`;
return this.prettifyCode(script);
}
/**
* Maps a proposed Donobu tool call to valid NodeJS Playwright code that uses
* the `DonobuExtendedPage` extension.
*/
static 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: {
return `${rationale}await page.visuallyAssert(${serializedParams});`;
}
case ChangeWebBrowserTabTool_1.ChangeWebBrowserTabTool.NAME: {
return `${rationale}page = page.context().pages().find((tab) => tab.url() === '${rawParams.tabUrl}');`;
}
case ChooseSelectOptionTool_1.ChooseSelectOptionTool.NAME: {
return `${rationale}await page.chooseSelectOption(${serializedParams});`;
}
case ClickTool_1.ClickTool.NAME: {
return `${rationale}await page.clickElement(${serializedParams});`;
}
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('${rawParams.url}');`;
case ReloadPageTool_1.ReloadPageTool.NAME:
return `${rationale}await page.reload();`;
case ScrollPageTool_1.ScrollPageTool.NAME:
return `${rationale}await page.scroll(${serializedParams});`;
// The following tools can be mapped directly.
case AnalyzePageTextTool_1.AnalyzePageTextTool.NAME:
case AssertPageTextTool_1.AssertPageTextTool.NAME:
case CreateBrowserCookieReportTool_1.CreateBrowserCookieReportTool.NAME:
case HoverOverElementTool_1.HoverOverElementTool.NAME:
case InputRandomizedEmailAddressTool_1.InputRandomizedEmailAddressTool.NAME:
case InputTextTool_1.InputTextTool.NAME:
case PressKeyTool_1.PressKeyTool.NAME:
case RunAccessibilityTestTool_1.RunAccessibilityTestTool.NAME: {
return `${rationale}await page.${proposedToolCall.name}(${hasNonEmptyParameters ? serializedParams : ''});`;
}
default:
const toolName = proposedToolCall.name;
const toolCallScript = hasNonEmptyParameters
? `${rationale}await page.run('${toolName}', ${serializedParams});`
: `${rationale}await page.run('${toolName}');`;
return toolCallScript;
}
}
async prettifyCode(code) {
// Shenanigans way of importing prettier due to issues with Jest.
// See https://github.com/jestjs/jest/issues/14305
const prettier = require('prettier');
const formattedCode = prettier.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
*/
sanitizeForTemplateLiteral(jsonString) {
return (jsonString
// Escape backticks to prevent template literal termination
.replace(/`/g, '\\`')
// Escape ${...} patterns to prevent string interpolation
.replace(/\${/g, '\\${'));
}
}
exports.CodeGenerator = CodeGenerator;
//# sourceMappingURL=CodeGenerator.js.map