chore: add the codegen tool (#36446)

This commit is contained in:
Pavel Feldman 2025-06-26 08:31:20 -07:00 committed by GitHub
parent bf5fea29f2
commit 216e6be464
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 243 additions and 40 deletions

View File

@ -44,7 +44,8 @@
"roll": "node utils/roll_browser.js",
"check-deps": "node utils/check_deps.js",
"build-android-driver": "./utils/build_android_driver.sh",
"innerloop": "playwright run-server --reuse-browser"
"innerloop": "playwright run-server --reuse-browser",
"mdd": "playwright-mdd packages/playwright-mdd/specs/integration.spec.md -o packages/playwright-mdd/tests/integration.spec.ts"
},
"workspaces": [
"packages/*"

View File

@ -0,0 +1,8 @@
# Pass
- Navigate to https://debs-obrien.github.io/playwright-movies-app
- Click search icon
- Type "Twister" in the search field and hit Enter
- Verify that the URL contains the search term "twister"
- Verify that the search results contain an image named "Twisters"
- Click on the link for the movie "Twisters"
- Verify that the main heading on the movie page is "Twisters"

View File

@ -58,14 +58,15 @@ const assertURL = defineTool({
},
handle: async (context, params) => {
const flags = params.ignoreCase ? 'i' : '';
const code = [
`await expect(page).toHaveURL(/${params.url}/);`
`await expect(page).toHaveURL(/${params.url}/${flags});`
];
return {
code,
action: async () => {
const re = new RegExp(params.url, params.ignoreCase ? 'i' : '');
const re = new RegExp(params.url, flags);
const url = context.page.url();
if (!re.test(url))
throw new Error(`Expected URL to match ${params.url}, but got ${url}.`);

View File

@ -81,21 +81,14 @@ export class Context {
return result;
}
async runScript(tasks: string[]): Promise<string> {
this._codeCollector = [
`test('generated code', async ({ page }) => {`,
];
async runScript(tasks: string[]): Promise<{ code: string[] }> {
await runTasks(this, tasks);
this._codeCollector.push('});');
return this._codeCollector.join('\n');
return { code: this._codeCollector };
}
async beforeTask(task: string) {
this._codeCollector.push(`// ${task}`);
}
async afterTask() {
this._codeCollector.push('');
this._codeCollector.push(`// ${task}`);
}
async runTool(tool: Tool, params: Record<string, unknown> | undefined): Promise<{ content: string }> {
@ -136,7 +129,7 @@ export class Context {
if (captureSnapshot && !this._javaScriptBlocked())
result.push(await this._snapshot());
this._codeCollector.push(...code.map(c => ` ${c}`));
this._codeCollector.push(...code);
return { content: result.join('\n') };
}

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { tools } from './tools';
import { runTasks } from '../loop';
import type { Tool } from './tool';
export class Context {
readonly tools = tools;
private _codeCollector: string[] = [];
constructor() {
}
async runTool(tool: Tool<any>, params: Record<string, unknown>): Promise<{ content: string }> {
const { content, code } = await tool.handle(this, params);
this._codeCollector.push(...code);
return { content };
}
async generateCode(content: string) {
await runTasks(this, ['Generate code for the following test spec: ' + content]);
return this._codeCollector.join('\n');
}
}

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool';
const doneTool = defineTool({
schema: {
name: 'done',
description: 'Call this tool to indicate that the task is complete',
inputSchema: z.object({}),
},
handle: async () => ({ content: 'Done', code: [] }),
});
export default [
doneTool,
];

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { Context as BrowserContext } from '../browser/context';
const generateCode = defineTool({
schema: {
name: 'codegen',
description: 'Generate code for the given test spec',
inputSchema: z.object({
tests: z.array(z.object({
name: z.string(),
steps: z.array(z.string()),
})),
}),
},
handle: async (context, params) => {
const { tests } = params;
const code: string[] = [
`/* eslint-disable notice/notice */`,
'',
`import { test, expect } from '@playwright/test';`,
'',
];
for (const test of tests) {
code.push(`test('${test.name}', async ({ page }) => {`);
const context = await BrowserContext.create();
const result = await context.runScript(test.steps);
code.push(...result.code.map(c => c ? ` ${c}` : ''));
code.push('});');
code.push('');
await context.close();
}
return {
content: 'Generated code has been saved and delivered to the user. Call the "done" tool, do not produce any other output.',
code
};
},
});
export default [
generateCode,
];

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { z } from 'zod';
import type { Context } from './context';
import type { ToolSchema } from '../loop';
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<{ content: string, code: string[] }>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import generateCode from './generateCode';
import done from './done';
import type { Tool } from './tool.js';
export const tools: Tool<any>[] = [
...generateCode,
...done,
];

View File

@ -14,10 +14,12 @@
* limitations under the License.
*/
import fs from 'fs';
import dotenv from 'dotenv';
import { program } from 'commander';
import { Context } from './browser/context';
import { Context } from './codegen/context';
/* eslint-disable no-console */
@ -27,32 +29,17 @@ const packageJSON = require('../package.json');
program
.version('Version ' + packageJSON.version)
.argument('<spec>', 'The test spec to generate code for')
.option('-o, --output <path>', 'The path to save the generated code')
.name(packageJSON.name)
.action(async () => {
const context = await Context.create();
const code = await context.runScript(script);
console.log('Output code:');
console.log('```javascript');
console.log(code);
console.log('```');
await context.close();
.action(async (spec, options) => {
const content = await fs.promises.readFile(spec, 'utf8');
const codegenContext = new Context();
const code = await codegenContext.generateCode(content);
if (options.output)
await fs.promises.writeFile(options.output, code);
else
console.log(code);
});
// An example of a failing script.
//
// const script = [
// 'Navigate to https://debs-obrien.github.io/playwright-movies-app/search?searchTerm=Twister&page=1',
// 'Verify that the URL contains the search term "twisters"',
// ];
const script = [
'Navigate to https://debs-obrien.github.io/playwright-movies-app',
'Click search icon',
'Type "Twister" in the search field and hit Enter',
'Verify that the URL contains the search term "twister"',
'Verify that the search results contain an image named "Twisters"',
'Click on the link for the movie "Twisters"',
'Verify that the main heading on the movie page is "Twisters"',
];
export { program };

View File

@ -0,0 +1,28 @@
/* eslint-disable notice/notice */
import { test, expect } from '@playwright/test';
test('Search and verify Twisters movie', async ({ page }) => {
// Navigate to https://debs-obrien.github.io/playwright-movies-app
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
// Click search icon
await page.getByRole('search').click();
// Type "Twister" in the search field and hit Enter
await page.getByRole('textbox', { name: 'Search Input' }).fill('Twister');
await page.getByRole('textbox', { name: 'Search Input' }).press('Enter');
// Verify that the URL contains the search term "twister"
await expect(page).toHaveURL(/twister/i);
// Verify that the search results contain an image named "Twisters"
await expect(page.getByRole('link', { name: 'poster of Twisters Twisters' })).toBeVisible();
// Click on the link for the movie "Twisters"
await page.getByRole('link', { name: 'poster of Twisters Twisters' }).click();
// Verify that the main heading on the movie page is "Twisters"
await expect(page.getByTestId('movie-summary').getByRole('heading', { name: 'Twisters' })).toBeVisible();
});