chore: park -mdd experiment (#36660)

This commit is contained in:
Pavel Feldman 2025-07-14 08:34:17 -07:00 committed by GitHub
parent 9605c78806
commit 27ad487ed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 5 additions and 1614 deletions

80
package-lock.json generated
View File

@ -1373,10 +1373,6 @@
"resolved": "packages/playwright-ct-vue",
"link": true
},
"node_modules/@playwright/mdd": {
"resolved": "packages/playwright-mdd",
"link": true
},
"node_modules/@playwright/test": {
"resolved": "packages/playwright-test",
"link": true
@ -1758,16 +1754,6 @@
"@types/tern": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -1826,13 +1812,6 @@
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "18.19.76",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.76.tgz",
@ -2999,15 +2978,6 @@
"node": ">=0.1.90"
}
},
"node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3338,6 +3308,7 @@
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@ -5962,27 +5933,6 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.7.0.tgz",
"integrity": "sha512-zXWawZl6J/P5Wz57/nKzVT3kJQZvogfuyuNVCdEp4/XU2UNrjL7SsuNpWAyLZbo6HVymwmnfno9toVzBhelygA==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -7862,7 +7812,7 @@
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -8006,20 +7956,12 @@
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
"integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"packages/html-reporter": {
"version": "0.0.0"
},
@ -8767,6 +8709,7 @@
"packages/playwright-mdd": {
"name": "@playwright/mdd",
"version": "0.0.1",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"commander": "^13.1.0",
@ -8787,21 +8730,6 @@
"node": ">=18"
}
},
"packages/playwright-mdd/node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/playwright-test": {
"name": "@playwright/test",
"version": "1.55.0-next",

View File

@ -44,8 +44,7 @@
"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",
"mdd": "playwright-mdd run packages/playwright-mdd/specs/integration.spec.md -o packages/playwright-mdd/tests/integration.spec.ts"
"innerloop": "playwright run-server --reuse-browser"
},
"workspaces": [
"packages/*"

View File

@ -1,19 +0,0 @@
#!/usr/bin/env node
/**
* 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.
*/
const { program } = require('./lib/program');
void program.parseAsync(process.argv);

View File

@ -1,33 +0,0 @@
{
"name": "@playwright/mdd",
"version": "0.0.1",
"description": "Playwright MDD",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"dependencies": {
"commander": "^13.1.0",
"debug": "^4.4.1",
"dotenv": "^16.5.0",
"mime": "^4.0.7",
"openai": "^5.7.0",
"playwright-core": "1.55.0-next",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@types/debug": "^4.1.7"
},
"bin": {
"playwright-mdd": "cli.js"
}
}

View File

@ -1,8 +0,0 @@
# 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

@ -1,83 +0,0 @@
/**
* 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 { generateLocator } from './utils';
import { elementSchema } from './snapshot';
const assertVisible = defineTool({
schema: {
name: 'browser_assert_visible',
description: 'Assert that an element is visible on the page',
inputSchema: elementSchema,
},
handle: async (context, params) => {
const locator = context.refLocator(params);
const code = [
`await expect(page.${await generateLocator(locator)}).toBeVisible();`
];
return {
code,
action: async () => {
const isVisible = await locator.isVisible();
if (!isVisible)
throw new Error(`Expected ${params.element} to be visible, but it is hidden.`);
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});
const assertURL = defineTool({
schema: {
name: 'browser_assert_url',
description: 'Assert that the current page URL matches the expected URL',
inputSchema: z.object({
url: z.string().describe('The expected URL regular expression, e.g. ".*example.com/path/.*"'),
ignoreCase: z.boolean().optional().default(true).describe('Whether to ignore case when matching the URL'),
}),
},
handle: async (context, params) => {
const flags = params.ignoreCase ? 'i' : '';
const code = [
`await expect(page).toHaveURL(/${params.url}/${flags});`
];
return {
code,
action: async () => {
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}.`);
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});
export default [
assertVisible,
assertURL,
];

View File

@ -1,237 +0,0 @@
/**
* 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 * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './utils';
import { ManualPromise } from '../manualPromise';
import { tools } from './tools';
import { runTasks } from '../loop';
import type { ModalState, Tool, ToolActionResult } from './tool';
type PendingAction = {
dialogShown: ManualPromise<void>;
};
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class Context {
readonly browser: playwright.Browser;
readonly page: playwright.Page;
readonly tools = tools;
private _modalStates: ModalState[] = [];
private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
private _codeCollector: string[] = [];
constructor(browser: playwright.Browser, page: playwright.Page) {
this.browser = browser;
this.page = page;
}
static async create(): Promise<Context> {
const browser = await playwright.chromium.launch({
headless: false,
});
const context = await browser.newContext();
const page = await context.newPage();
return new Context(browser, page);
}
async close() {
await this.browser.close();
}
modalStates(): ModalState[] {
return this._modalStates;
}
setModalState(modalState: ModalState) {
this._modalStates.push(modalState);
}
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
async runScript(tasks: string[]): Promise<{ code: string[] }> {
await runTasks(this, tasks);
return { code: this._codeCollector };
}
async beforeTask(task: string) {
this._codeCollector.push('');
this._codeCollector.push(`// ${task}`);
}
async runTool(tool: Tool, params: Record<string, unknown> | undefined): Promise<{ content: string }> {
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot } = toolResult;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
if (waitForNetwork)
await waitForCompletion(this, async () => racingAction?.());
else
await racingAction?.();
const result: string[] = [];
if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown());
return {
content: result.join('\n'),
};
}
if (this._downloads.length) {
result.push('', '### Downloads');
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
result.push('');
}
result.push(
`- Page URL: ${this.page.url()}`,
`- Page Title: ${await this.title()}`
);
if (captureSnapshot && !this._javaScriptBlocked())
result.push(await this._snapshot());
this._codeCollector.push(...code);
return { content: result.join('\n') };
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForTimeout(time: number) {
if (this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
}
async navigate(url: string) {
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) {
const e = _e as Error;
const mightBeDownload =
e.message.includes('net::ERR_ABORTED') // chromium
|| e.message.includes('Download is starting'); // firefox + webkit
if (!mightBeDownload)
throw e;
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([
downloadEvent,
new Promise(resolve => setTimeout(resolve, 1000)),
]);
if (!download)
throw e;
}
// Cap load event to 5 seconds, the page is operational at this point.
await this.waitForLoadState('load', { timeout: 5000 });
}
refLocator(params: { element: string, ref: string }): playwright.Locator {
return this.page.locator(`aria-ref=${params.ref}`).describe(params.element);
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
this._pendingAction = {
dialogShown: new ManualPromise(),
};
let result: ToolActionResult | undefined;
try {
await Promise.race([
action().then(r => result = r),
this._pendingAction.dialogShown,
]);
} finally {
this._pendingAction = undefined;
}
return result;
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
dialogShown(dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
});
this._pendingAction?.dialogShown.resolve();
}
async downloadStarted(download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: this._outputFile(download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private async _snapshot() {
const snapshot = await callOnPageNoTrace(this.page, page => (page as PageEx)._snapshotForAI());
return [
`- Page Snapshot`,
'```yaml',
snapshot,
'```',
].join('\n');
}
private _outputFile(filename: string) {
return filename;
}
}

View File

@ -1,39 +0,0 @@
/**
* 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 () => {
return {
code: [],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
export default [
doneTool,
];

View File

@ -1,53 +0,0 @@
/**
* 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.
*/
// adapted from:
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
// NOTE: this function should not be used to escape any selectors.
export function escapeWithQuotes(text: string, char: string = '\'') {
const stringified = JSON.stringify(text);
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
if (char === '\'')
return char + escapedText.replace(/[']/g, '\\\'') + char;
if (char === '"')
return char + escapedText.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + escapedText.replace(/[`]/g, '`') + char;
throw new Error('Invalid escape char');
}
export function quote(text: string) {
return escapeWithQuotes(text, '\'');
}
export function formatObject(value: any, indent = ' '): string {
if (typeof value === 'string')
return quote(value);
if (Array.isArray(value))
return `[${value.map(o => formatObject(o)).join(', ')}]`;
if (typeof value === 'object') {
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length)
return '{}';
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${key}: ${formatObject(value[key])}`);
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
}
return String(value);
}

View File

@ -1,88 +0,0 @@
/**
* 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.js';
const navigate = defineTool({
schema: {
name: 'browser_navigate',
description: 'Navigate to a URL',
inputSchema: z.object({
url: z.string().describe('The URL to navigate to'),
}),
},
handle: async (context, params) => {
const code = [
`await page.goto('${params.url}');`,
];
await context.navigate(params.url);
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goBack = defineTool({
schema: {
name: 'browser_navigate_back',
description: 'Go back to the previous page',
inputSchema: z.object({}),
},
handle: async context => {
await context.page.goBack();
const code = [
`await page.goBack();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goForward = defineTool({
schema: {
name: 'browser_navigate_forward',
description: 'Go forward to the next page',
inputSchema: z.object({}),
},
handle: async context => {
await context.page.goForward();
const code = [
`await page.goForward();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
export default [
navigate,
goBack,
goForward,
];

View File

@ -1,194 +0,0 @@
/**
* 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 * as format from './format';
import { generateLocator } from './utils';
const snapshot = defineTool({
schema: {
name: 'browser_snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
inputSchema: z.object({}),
},
handle: async () => {
return {
code: [],
captureSnapshot: true,
waitForNetwork: false,
};
},
});
export const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().describe('Exact target element reference from the page snapshot'),
});
const click = defineTool({
schema: {
name: 'browser_click',
description: 'Perform click on a web page',
inputSchema: elementSchema,
},
handle: async (context, params) => {
const locator = context.refLocator(params);
const code = [
`await page.${await generateLocator(locator)}.click();`
];
return {
code,
action: () => locator.click(),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const drag = defineTool({
schema: {
name: 'browser_drag',
description: 'Perform drag and drop between two elements',
inputSchema: z.object({
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
startRef: z.string().describe('Exact source element reference from the page snapshot'),
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
endRef: z.string().describe('Exact target element reference from the page snapshot'),
}),
},
handle: async (context, params) => {
const startLocator = context.refLocator({ ref: params.startRef, element: params.startElement });
const endLocator = context.refLocator({ ref: params.endRef, element: params.endElement });
const code = [
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
return {
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const hover = defineTool({
schema: {
name: 'browser_hover',
description: 'Hover over element on page',
inputSchema: elementSchema,
},
handle: async (context, params) => {
const locator = context.refLocator(params);
const code = [
`await page.${await generateLocator(locator)}.hover();`
];
return {
code,
action: () => locator.hover(),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
const type = defineTool({
schema: {
name: 'browser_type',
description: 'Type text into editable element',
inputSchema: typeSchema,
},
handle: async (context, params) => {
const locator = context.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
if (params.slowly) {
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${format.quote(params.text)});`);
steps.push(() => locator.pressSequentially(params.text));
} else {
code.push(`await page.${await generateLocator(locator)}.fill(${format.quote(params.text)});`);
steps.push(() => locator.fill(params.text));
}
if (params.submit) {
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
}
return {
code,
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const selectOptionSchema = elementSchema.extend({
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
});
const selectOption = defineTool({
schema: {
name: 'browser_select_option',
description: 'Select an option in a dropdown',
inputSchema: selectOptionSchema,
},
handle: async (context, params) => {
const locator = context.refLocator(params);
const code = [
`await page.${await generateLocator(locator)}.selectOption(${format.formatObject(params.values)});`
];
return {
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
export default [
snapshot,
click,
drag,
hover,
type,
selectOption,
];

View File

@ -1,53 +0,0 @@
/**
* 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 * as playwright from 'playwright-core';
import type { Context } from './context';
import type { ToolSchema } from '../loop';
export type FileUploadModalState = {
type: 'fileChooser';
description: string;
fileChooser: playwright.FileChooser;
};
export type DialogModalState = {
type: 'dialog';
description: string;
dialog: playwright.Dialog;
};
export type ModalState = FileUploadModalState | DialogModalState;
export type ToolActionResult = string | undefined | void;
export type ToolResult = {
code: string[];
action?: () => Promise<void>;
captureSnapshot: boolean;
waitForNetwork: boolean;
};
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@ -1,29 +0,0 @@
/**
* 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 assert from './assert';
import snapshot from './snapshot';
import done from './done';
import navigate from './navigate';
import type { Tool } from './tool.js';
export const tools: Tool<any>[] = [
...assert,
...navigate,
...snapshot,
...done,
];

View File

@ -1,91 +0,0 @@
/**
* 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 * as playwright from 'playwright';
import type { Context } from './context';
export async function waitForCompletion<R>(context: Context, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
const waitBarrier = new Promise<void>(f => { waitCallback = f; });
const requestListener = (request: playwright.Request) => requests.add(request);
const requestFinishedListener = (request: playwright.Request) => {
requests.delete(request);
if (!requests.size)
waitCallback();
};
const frameNavigateListener = (frame: playwright.Frame) => {
if (frame.parentFrame())
return;
frameNavigated = true;
dispose();
clearTimeout(timeout);
void context.waitForLoadState('load').then(waitCallback);
};
const onTimeout = () => {
dispose();
waitCallback();
};
context.page.on('request', requestListener);
context.page.on('requestfinished', requestFinishedListener);
context.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000);
const dispose = () => {
context.page.off('request', requestListener);
context.page.off('requestfinished', requestFinishedListener);
context.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout);
};
try {
const result = await callback();
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
await context.page.waitForTimeout(1000);
return result;
} finally {
dispose();
}
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
try {
return await (locator as any)._generateLocatorString();
} catch (e) {
if (e instanceof Error && /locator._generateLocatorString: Timeout .* exceeded/.test(e.message))
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
throw e;
}
}
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
}

View File

@ -1,39 +0,0 @@
/**
* 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

@ -1,33 +0,0 @@
/**
* 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

@ -1,60 +0,0 @@
/**
* 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

@ -1,28 +0,0 @@
/**
* 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

@ -1,25 +0,0 @@
/**
* 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

@ -1,149 +0,0 @@
/**
* 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 OpenAI from 'openai';
import debug from 'debug';
import { zodToJsonSchema } from 'zod-to-json-schema';
const model = 'gpt-4.1';
/* eslint-disable no-console */
export interface Context {
readonly tools: Tool<any>[];
beforeTask?(task: string): Promise<void>;
runTool(tool: Tool<any>, params: Record<string, unknown>): Promise<{ content: string }>;
afterTask?(): Promise<void>;
}
export type ToolSchema<Input extends z.Schema> = {
name: string;
description: string;
inputSchema: Input;
};
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
};
export async function runTasks(context: Context, tasks: string[]) {
const openai = new OpenAI();
for (const task of tasks)
await runTask(openai, context, task);
}
async function runTask(openai: OpenAI, context: Context, task: string) {
console.log('Perform task:', task);
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: 'user',
content: `Peform following task: ${task}. Once the task is complete, call the "done" tool.`
}
];
await context.beforeTask?.(task);
for (let iteration = 0; iteration < 5; ++iteration) {
debug('history')(messages);
const response = await openai.chat.completions.create({
model,
messages,
tools: context.tools.map(asOpenAIDeclaration),
tool_choice: 'auto'
});
const message = response.choices[0].message;
if (!message.tool_calls?.length)
throw new Error('Unexpected response from LLM: ' + message.content);
messages.push({
role: 'assistant',
tool_calls: message.tool_calls
});
for (const toolCall of message.tool_calls) {
const functionCall = toolCall.function;
console.log('Call tool:', functionCall.name, functionCall.arguments);
const tool = context.tools.find(tool => tool.schema.name === functionCall.name);
if (!tool)
throw new Error('Unknown tool: ' + functionCall.name);
if (functionCall.name === 'done') {
await context.afterTask?.();
return;
}
try {
const { content } = await context.runTool(tool, JSON.parse(functionCall.arguments));
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content,
});
} catch (error) {
console.log('Tool error:', error);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: `Error while executing tool "${functionCall.name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
});
for (const ignoredToolCall of message.tool_calls.slice(message.tool_calls.indexOf(toolCall) + 1)) {
messages.push({
role: 'tool',
tool_call_id: ignoredToolCall.id,
content: `This tool call is skipped due to previous error.`,
});
}
break;
}
}
}
throw new Error('Failed to perform step, max attempts reached');
}
function asOpenAIDeclaration(tool: Tool<any>): OpenAI.Chat.Completions.ChatCompletionTool {
const parameters = zodToJsonSchema(tool.schema.inputSchema);
delete parameters.$schema;
delete (parameters as any).additionalProperties;
return {
type: 'function',
function: {
name: tool.schema.name,
description: tool.schema.description,
parameters,
},
};
}
export async function runOneShot(prompt: string): Promise<string> {
const openai = new OpenAI();
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: 'user',
content: prompt
}
];
const response = await openai.chat.completions.create({
model,
messages,
});
const message = response.choices[0].message;
if (!message.content)
throw new Error('Unexpected response from LLM: ' + message.content);
return message.content;
}

View File

@ -1,127 +0,0 @@
/**
* 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.
*/
export class ManualPromise<T = void> extends Promise<T> {
private _resolve!: (t: T) => void;
private _reject!: (e: Error) => void;
private _isDone: boolean;
constructor() {
let resolve: (t: T) => void;
let reject: (e: Error) => void;
super((f, r) => {
resolve = f;
reject = r;
});
this._isDone = false;
this._resolve = resolve!;
this._reject = reject!;
}
isDone() {
return this._isDone;
}
resolve(t: T) {
this._isDone = true;
this._resolve(t);
}
reject(e: Error) {
this._isDone = true;
this._reject(e);
}
static override get [Symbol.species]() {
return Promise;
}
override get [Symbol.toStringTag]() {
return 'ManualPromise';
}
}
export class LongStandingScope {
private _terminateError: Error | undefined;
private _closeError: Error | undefined;
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;
reject(error: Error) {
this._isClosed = true;
this._terminateError = error;
for (const p of this._terminatePromises.keys())
p.resolve(error);
}
close(error: Error) {
this._isClosed = true;
this._closeError = error;
for (const [p, frames] of this._terminatePromises)
p.resolve(cloneError(error, frames));
}
isClosed() {
return this._isClosed;
}
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
return Promise.race(scopes.map(s => s.race(promise)));
}
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
}
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {
return this._race([promise], true, defaultValue);
}
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
const terminatePromise = new ManualPromise<Error>();
const frames = captureRawStack();
if (this._terminateError)
terminatePromise.resolve(this._terminateError);
if (this._closeError)
terminatePromise.resolve(cloneError(this._closeError, frames));
this._terminatePromises.set(terminatePromise, frames);
try {
return await Promise.race([
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
...promises
]);
} finally {
this._terminatePromises.delete(terminatePromise);
}
}
}
function cloneError(error: Error, frames: string[]) {
const clone = new Error();
clone.name = error.name;
clone.message = error.message;
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
return clone;
}
function captureRawStack(): string[] {
const stackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 50;
const error = new Error();
const stack = error.stack || '';
Error.stackTraceLimit = stackTraceLimit;
return stack.split('\n');
}

View File

@ -1,52 +0,0 @@
/**
* 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 fs from 'fs';
import dotenv from 'dotenv';
import { program } from 'commander';
import { Context } from './codegen/context';
import { runRecorderLoop } from './recorderLoop';
/* eslint-disable no-console */
dotenv.config();
const packageJSON = require('../package.json');
program
.command('run <spec>').description('Run a test')
.version('Version ' + packageJSON.version)
.option('-o, --output <path>', 'The path to save the generated code')
.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);
});
program
.command('record').description('Record a test')
.version('Version ' + packageJSON.version)
.action(async () => {
await runRecorderLoop();
});
export { program };

View File

@ -1,63 +0,0 @@
/**
* 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.
*/
/* eslint-disable no-console */
import { chromium } from 'playwright-core';
import { runOneShot } from './loop';
import type { BrowserContext } from '../../playwright-core/src/client/browserContext';
import type * as actions from '@recorder/actions';
import type * as playwright from 'playwright-core';
export async function runRecorderLoop() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext() as BrowserContext;
await context._enableRecorder({
mode: 'recording',
recorderMode: 'api',
}, {
actionAdded: (page: playwright.Page, actionInContext: actions.ActionInContext) => {
const action = actionInContext.action;
if (action.name !== 'click' && action.name !== 'press')
return;
runOneShot(prompt(action)).then(response => {
console.log(response);
}).catch(e => {
console.error(e);
});
},
actionUpdated: (page: playwright.Page, actionInContext: actions.ActionInContext) => {
console.log('actionUpdated', actionInContext);
},
signalAdded: (page: playwright.Page, signal: actions.SignalInContext) => {
console.log('signalAdded', signal);
},
});
const page = await context.newPage();
await page.goto('https://playwright.dev/');
}
const prompt = (action: actions.ClickAction | actions.PressAction) => [
`- User performed an action on a page.`,
`- Please describe the action in a single phrase.`,
`- You'll be asked to perform the action again, so make sure to describe the action in a way that is easy to understand and perform.`,
`- Action: "${action.name}"`,
`- Element: [${action.selector}]`,
`- Snapshot:`,
action.ariaSnapshot,
].join('\n');

View File

@ -1,28 +0,0 @@
/* 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();
});

View File

@ -219,11 +219,6 @@ const workspace = new Workspace(ROOT_PATH, [
path: path.join(ROOT_PATH, 'packages', 'playwright-ct-vue'),
files: ['LICENSE'],
}),
new PWPackage({
name: 'playwright-mdd',
path: path.join(ROOT_PATH, 'packages', 'playwright-mdd'),
files: ['LICENSE'],
}),
]);
if (require.main === module) {