diff --git a/package.json b/package.json index 6171609f6c..31ddee8dcf 100644 --- a/package.json +++ b/package.json @@ -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/*" diff --git a/packages/playwright-mdd/specs/integration.spec.md b/packages/playwright-mdd/specs/integration.spec.md new file mode 100644 index 0000000000..5ebdcade43 --- /dev/null +++ b/packages/playwright-mdd/specs/integration.spec.md @@ -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" diff --git a/packages/playwright-mdd/src/browser/assert.ts b/packages/playwright-mdd/src/browser/assert.ts index 38eae843ac..3cbc2b542b 100644 --- a/packages/playwright-mdd/src/browser/assert.ts +++ b/packages/playwright-mdd/src/browser/assert.ts @@ -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}.`); diff --git a/packages/playwright-mdd/src/browser/context.ts b/packages/playwright-mdd/src/browser/context.ts index e296e101bc..b41cb9ba4f 100644 --- a/packages/playwright-mdd/src/browser/context.ts +++ b/packages/playwright-mdd/src/browser/context.ts @@ -81,21 +81,14 @@ export class Context { return result; } - async runScript(tasks: string[]): Promise { - 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 | 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') }; } diff --git a/packages/playwright-mdd/src/codegen/context.ts b/packages/playwright-mdd/src/codegen/context.ts new file mode 100644 index 0000000000..0ab38ae9a0 --- /dev/null +++ b/packages/playwright-mdd/src/codegen/context.ts @@ -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, params: Record): 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'); + } +} diff --git a/packages/playwright-mdd/src/codegen/done.ts b/packages/playwright-mdd/src/codegen/done.ts new file mode 100644 index 0000000000..7a6d69f784 --- /dev/null +++ b/packages/playwright-mdd/src/codegen/done.ts @@ -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, +]; diff --git a/packages/playwright-mdd/src/codegen/generateCode.ts b/packages/playwright-mdd/src/codegen/generateCode.ts new file mode 100644 index 0000000000..12196b877d --- /dev/null +++ b/packages/playwright-mdd/src/codegen/generateCode.ts @@ -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, +]; diff --git a/packages/playwright-mdd/src/codegen/tool.ts b/packages/playwright-mdd/src/codegen/tool.ts new file mode 100644 index 0000000000..35d6c2bf51 --- /dev/null +++ b/packages/playwright-mdd/src/codegen/tool.ts @@ -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 = { + schema: ToolSchema; + handle: (context: Context, params: z.output) => Promise<{ content: string, code: string[] }>; +}; + +export function defineTool(tool: Tool): Tool { + return tool; +} diff --git a/packages/playwright-mdd/src/codegen/tools.ts b/packages/playwright-mdd/src/codegen/tools.ts new file mode 100644 index 0000000000..b6199406fc --- /dev/null +++ b/packages/playwright-mdd/src/codegen/tools.ts @@ -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[] = [ + ...generateCode, + ...done, +]; diff --git a/packages/playwright-mdd/src/program.ts b/packages/playwright-mdd/src/program.ts index 4d9a4f4963..396103dc5a 100644 --- a/packages/playwright-mdd/src/program.ts +++ b/packages/playwright-mdd/src/program.ts @@ -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('', 'The test spec to generate code for') + .option('-o, --output ', '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 }; diff --git a/packages/playwright-mdd/tests/integration.spec.ts b/packages/playwright-mdd/tests/integration.spec.ts new file mode 100644 index 0000000000..52437f1998 --- /dev/null +++ b/packages/playwright-mdd/tests/integration.spec.ts @@ -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(); +});