diff --git a/package-lock.json b/package-lock.json index 3ac58bc145d17..da1d9b61133cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1373,6 +1373,10 @@ "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 @@ -1754,6 +1758,16 @@ "@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", @@ -1812,6 +1826,13 @@ "@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", @@ -2978,6 +2999,15 @@ "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", @@ -3139,9 +3169,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3305,10 +3335,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "dev": true, + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5933,6 +5962,27 @@ "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", @@ -7809,10 +7859,10 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7956,12 +8006,20 @@ "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" }, @@ -8706,6 +8764,44 @@ "node": ">=18" } }, + "packages/playwright-mdd": { + "name": "@playwright/mdd", + "version": "0.0.1", + "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.54.0-next", + "zod-to-json-schema": "^3.24.4" + }, + "bin": { + "playwright-mdd": "cli.js" + }, + "devDependencies": { + "@types/debug": "^4.1.7" + }, + "engines": { + "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.54.0-next", diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index bcc6be892e4e9..e31bf7b1c42f3 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -748,7 +748,7 @@ export class InjectedScript { throw this.createStacklessError(`Unexpected element state "${state}"`); } - selectOptions(node: Node, optionsToSelect: (Node | { valueOrLabel?: string, value?: string, label?: string, index?: number })[]): string[] | 'error:notconnected' | 'error:optionsnotfound' { + selectOptions(node: Node, optionsToSelect: (Node | { valueOrLabel?: string, value?: string, label?: string, index?: number })[]): string[] | 'error:notconnected' | 'error:optionsnotfound' | 'error:optionnotenabled' { const element = this.retarget(node, 'follow-label'); if (!element) return 'error:notconnected'; @@ -776,6 +776,8 @@ export class InjectedScript { }; if (!remainingOptionsToSelect.some(filter)) continue; + if (!this.elementState(option, 'enabled').matches) + return 'error:optionnotenabled'; selectedOptions.push(option); if (select.multiple) { remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o)); diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index 139b26bb6394b..75bb580dad60c 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -1060,8 +1060,12 @@ export function getAriaDisabled(element: Element): boolean { function isNativelyDisabled(element: Element) { // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings - const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.tagName); - return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element)); + const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(elementSafeTagName(element)); + return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledOptGroup(element) || belongsToDisabledFieldSet(element)); +} + +function belongsToDisabledOptGroup(element: Element): boolean { + return elementSafeTagName(element) === 'OPTION' && !!element.closest('OPTGROUP[DISABLED]'); } function belongsToDisabledFieldSet(element: Element): boolean { diff --git a/packages/playwright-core/ThirdPartyNotices.txt b/packages/playwright-core/ThirdPartyNotices.txt index 744720e00d113..d2aeb156ad027 100644 --- a/packages/playwright-core/ThirdPartyNotices.txt +++ b/packages/playwright-core/ThirdPartyNotices.txt @@ -10,7 +10,7 @@ This project incorporates components from the projects listed below. The origina - buffer-crc32@0.2.13 (https://github.com/brianloveswords/buffer-crc32) - codemirror@5.65.18 (https://github.com/codemirror/CodeMirror) - colors@1.4.0 (https://github.com/Marak/colors.js) -- commander@8.3.0 (https://github.com/tj/commander.js) +- commander@13.1.0 (https://github.com/tj/commander.js) - concat-map@0.0.1 (https://github.com/substack/node-concat-map) - debug@4.3.4 (https://github.com/debug-js/debug) - debug@4.4.0 (https://github.com/debug-js/debug) @@ -331,7 +331,7 @@ THE SOFTWARE. ========================================= END OF colors@1.4.0 AND INFORMATION -%% commander@8.3.0 NOTICES AND INFORMATION BEGIN HERE +%% commander@13.1.0 NOTICES AND INFORMATION BEGIN HERE ========================================= (The MIT License) @@ -356,7 +356,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= -END OF commander@8.3.0 AND INFORMATION +END OF commander@13.1.0 AND INFORMATION %% concat-map@0.0.1 NOTICES AND INFORMATION BEGIN HERE ========================================= diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3b75b5f38177c..8c98ba59c6524 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,15 +15,15 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1342", + "revision": "1343", "installByDefault": false, - "browserVersion": "139.0.7248.0" + "browserVersion": "139.0.7258.0" }, { "name": "chromium-tip-of-tree-headless-shell", - "revision": "1342", + "revision": "1343", "installByDefault": false, - "browserVersion": "139.0.7248.0" + "browserVersion": "139.0.7258.0" }, { "name": "firefox", @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "2186", + "revision": "2187", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", diff --git a/packages/playwright-core/bundles/utils/package-lock.json b/packages/playwright-core/bundles/utils/package-lock.json index bc58353c99f08..7646662695132 100644 --- a/packages/playwright-core/bundles/utils/package-lock.json +++ b/packages/playwright-core/bundles/utils/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "colors": "1.4.0", - "commander": "8.3.0", + "commander": "^13.0.0", "debug": "^4.3.4", "diff": "^7.0.0", "dotenv": "^16.4.5", @@ -173,11 +173,12 @@ } }, "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=18" } }, "node_modules/concat-map": { diff --git a/packages/playwright-core/bundles/utils/package.json b/packages/playwright-core/bundles/utils/package.json index 7e7ff502ba7bc..c0559de57d1dd 100644 --- a/packages/playwright-core/bundles/utils/package.json +++ b/packages/playwright-core/bundles/utils/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "colors": "1.4.0", - "commander": "8.3.0", + "commander": "^13.0.0", "debug": "^4.3.4", "diff": "^7.0.0", "dotenv": "^16.4.5", diff --git a/packages/playwright-core/src/cli/programWithTestStub.ts b/packages/playwright-core/src/cli/programWithTestStub.ts index 29b5d87ed143e..e7c42575260ee 100644 --- a/packages/playwright-core/src/cli/programWithTestStub.ts +++ b/packages/playwright-core/src/cli/programWithTestStub.ts @@ -55,7 +55,9 @@ const kExternalPlaywrightTestCommands = [ ]; function addExternalPlaywrightTestCommands() { for (const [command, description] of kExternalPlaywrightTestCommands) { - const playwrightTest = program.command(command).allowUnknownOption(true); + const playwrightTest = program.command(command) + .allowUnknownOption(true) + .allowExcessArguments(true); playwrightTest.description(`${description} Available in @playwright/test package.`); playwrightTest.action(async () => { printPlaywrightTestError(command); diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 85c91e8c22d73..93a600336d698 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -61,11 +61,6 @@ export class ElementHandle extends JSHandle implements return Frame.fromNullable((await this._elementChannel.contentFrame()).frame); } - async _generateLocatorString(): Promise { - const value = (await this._elementChannel.generateLocatorString()).value; - return value === undefined ? null : value; - } - async getAttribute(name: string): Promise { const value = (await this._elementChannel.getAttribute({ name })).value; return value === undefined ? null : value; diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index ea780c2fbe34d..b52c99e92259d 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -250,7 +250,8 @@ export class Locator implements api.Locator { } async _generateLocatorString(): Promise { - return await this._withElement(h => h._generateLocatorString(), { title: 'Generate locator string', internal: true }); + const { value } = await this._frame._channel.generateLocatorString({ selector: this._selector }); + return value === undefined ? null : value; } async getAttribute(name: string, options?: TimeoutOptions): Promise { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2475526a43eb5..a44bb2d096578 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1651,6 +1651,12 @@ scheme.FrameFrameElementParams = tOptional(tObject({})); scheme.FrameFrameElementResult = tObject({ element: tChannel(['ElementHandle']), }); +scheme.FrameGenerateLocatorStringParams = tObject({ + selector: tString, +}); +scheme.FrameGenerateLocatorStringResult = tObject({ + value: tOptional(tString), +}); scheme.FrameHighlightParams = tObject({ selector: tString, }); @@ -2044,10 +2050,6 @@ scheme.ElementHandleFillParams = tObject({ scheme.ElementHandleFillResult = tOptional(tObject({})); scheme.ElementHandleFocusParams = tOptional(tObject({})); scheme.ElementHandleFocusResult = tOptional(tObject({})); -scheme.ElementHandleGenerateLocatorStringParams = tOptional(tObject({})); -scheme.ElementHandleGenerateLocatorStringResult = tObject({ - value: tOptional(tString), -}); scheme.ElementHandleGetAttributeParams = tObject({ name: tString, }); diff --git a/packages/playwright-core/src/server/dispatchers/elementHandlerDispatcher.ts b/packages/playwright-core/src/server/dispatchers/elementHandlerDispatcher.ts index c9c50829a9a56..4f708270482b8 100644 --- a/packages/playwright-core/src/server/dispatchers/elementHandlerDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/elementHandlerDispatcher.ts @@ -65,10 +65,6 @@ export class ElementHandleDispatcher extends JSHandleDispatcher return { frame: frame ? FrameDispatcher.from(this._browserContextDispatcher(), frame) : undefined }; } - async generateLocatorString(params: channels.ElementHandleGenerateLocatorStringParams, metadata: CallMetadata): Promise { - return { value: await this._elementHandle.generateLocatorString() }; - } - async getAttribute(params: channels.ElementHandleGetAttributeParams, metadata: CallMetadata): Promise { const value = await this._elementHandle.getAttribute(metadata, params.name); return { value: value === null ? undefined : value }; diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 38c31056f2304..5db8c7c56121b 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -178,6 +178,10 @@ export class FrameDispatcher extends Dispatcher { + return { value: await this._frame.generateLocatorString(metadata, params.selector) }; + } + async getAttribute(params: channels.FrameGetAttributeParams, metadata: CallMetadata): Promise { const value = await this._frame.getAttribute(metadata, params.selector, params.name, params); return { value: value === null ? undefined : value }; diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index f1f234864dfe9..60abad7dfb3b6 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import * as js from './javascript'; import { ProgressController } from './progress'; -import { asLocator, isUnderTest } from '../utils'; +import { isUnderTest } from '../utils'; import { prepareFilesForUpload } from './fileUploadUtils'; import * as rawInjectedScriptSource from '../generated/injectedScriptSource'; @@ -38,7 +38,7 @@ export type InputFilesItems = { }; type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down'; -type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | { missingState: ElementState } | { hitTargetDescription: string } | 'done'; +type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | { missingState: ElementState } | { hitTargetDescription: string } | 'done'; export class NonRecoverableDOMError extends Error { } @@ -183,38 +183,6 @@ export class ElementHandle extends js.JSHandle { return this._page.delegate.getContentFrame(this); } - async generateLocatorString(): Promise { - const selectors = await this._generateSelectorString(); - if (!selectors.length) - return; - return asLocator('javascript', selectors.reverse().join(' >> internal:control=enter-frame >> ')); - } - - private async _generateSelectorString(): Promise { - const selector = await this.evaluateInUtility(async ([injected, node]) => { - return injected.generateSelectorSimple(node as unknown as Element); - }, {}); - if (selector === 'error:notconnected') - return []; - - let frame: frames.Frame | null = this._frame; - const result: string[] = [selector]; - while (frame?.parentFrame()) { - const frameElement = await frame.frameElement(); - if (frameElement) { - const selector = await frameElement.evaluateInUtility(async ([injected, node]) => { - return injected.generateSelectorSimple(node as unknown as Element); - }, {}); - frameElement.dispose(); - if (selector === 'error:notconnected') - return []; - result.push(selector); - } - frame = frame.parentFrame(); - } - return result; - } - async getAttribute(metadata: CallMetadata, name: string): Promise { return this._frame.getAttribute(metadata, ':scope', name, { timeout: 0 }, this); } @@ -368,6 +336,10 @@ export class ElementHandle extends js.JSHandle { progress.log(' did not find some options'); continue; } + if (result === 'error:optionnotenabled') { + progress.log(' option being selected is not enabled'); + continue; + } if (typeof result === 'object' && 'hitTargetDescription' in result) { progress.log(` ${result.hitTargetDescription} intercepts pointer events`); continue; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 33e738ac7ddf0..d428f79eb953e 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -27,7 +27,7 @@ import * as network from './network'; import { Page } from './page'; import { isAbortError, ProgressController } from './progress'; import * as types from './types'; -import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime, renderTitleForCall } from '../utils'; +import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, renderTitleForCall } from '../utils'; import { isSessionClosedError } from './protocolError'; import { debugLogger } from './utils/debugLogger'; import { eventsHelper } from './utils/eventsHelper'; @@ -1212,6 +1212,38 @@ export class Frame extends SdkObject { }, options.timeout); } + async generateLocatorString(metadata: CallMetadata, selector: string): Promise { + const controller = new ProgressController(metadata, this); + return controller.run(async progress => { + const element = await progress.race(this.selectors.query(selector)); + if (!element) + throw new Error(`No element matching ${this._asLocator(selector)}`); + + const generated = await progress.race(element.evaluateInUtility(async ([injected, node]) => { + return injected.generateSelectorSimple(node as unknown as Element); + }, {})); + if (!generated) + throw new Error(`Unable to generate locator for ${this._asLocator(selector)}`); + + let frame: Frame | null = element._frame; + const result = [generated]; + while (frame?.parentFrame()) { + const frameElement = await progress.race(frame.frameElement()); + if (frameElement) { + const generated = await progress.race(frameElement.evaluateInUtility(async ([injected, node]) => { + return injected.generateSelectorSimple(node as unknown as Element); + }, {})); + frameElement.dispose(); + if (generated === 'error:notconnected' || !generated) + throw new Error(`Unable to generate locator for ${this._asLocator(selector)}`); + result.push(generated); + } + frame = frame.parentFrame(); + } + return asLocator(this._page.browserContext._browser.sdkLanguage(), result.reverse().join(' >> internal:control=enter-frame >> ')); + }); + } + async textContent(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions, scope?: dom.ElementHandle): Promise { return this._callOnElementOnceMatches(metadata, selector, (injected, element) => element.textContent, undefined, options, scope); } @@ -1385,26 +1417,21 @@ export class Frame extends SdkObject { } private async _expectImpl(metadata: CallMetadata, selector: string | undefined, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> { + const controller = new ProgressController(metadata, this); const lastIntermediateResult: { received?: any, isSet: boolean } = { isSet: false }; - try { - let timeout = options.timeout; - const start = timeout > 0 ? monotonicTime() : 0; - + return await controller.run(async progress => { // Step 1: perform locator handlers checkpoint with a specified timeout. - await (new ProgressController(metadata, this)).run(async progress => { - progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`); - if (selector) - progress.log(`waiting for ${this._asLocator(selector)}`); - await this._page.performActionPreChecks(progress); - }, timeout); + progress.log(`${renderTitleForCall(metadata)}${options.timeout ? ` with timeout ${options.timeout}ms` : ''}`); + if (selector) + progress.log(`waiting for ${this._asLocator(selector)}`); + await this._page.performActionPreChecks(progress); // Step 2: perform one-shot expect check without a timeout. // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. + progress.legacyDisableTimeout(); try { - const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => { - return await this._expectInternal(progress, selector, options, lastIntermediateResult); - }); + const resultOneShot = await this._expectInternal(progress, selector, options, lastIntermediateResult, true); if (resultOneShot.matches !== options.isNot) return resultOneShot; } catch (e) { @@ -1412,28 +1439,21 @@ export class Frame extends SdkObject { throw e; // Ignore any other errors from one-shot, we'll handle them during retries. } - if (timeout > 0) { - const elapsed = monotonicTime() - start; - timeout -= elapsed; - } - if (timeout < 0) - return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received }; + progress.legacyEnableTimeout(); // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. - return await (new ProgressController(metadata, this)).run(async progress => { - return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { - await this._page.performActionPreChecks(progress); - const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult); - if (matches === options.isNot) { - // Keep waiting in these cases: - // expect(locator).conditionThatDoesNotMatch - // expect(locator).not.conditionThatDoesMatch - return continuePolling; - } - return { matches, received }; - }); - }, timeout); - } catch (e) { + return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { + await this._page.performActionPreChecks(progress); + const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult, false); + if (matches === options.isNot) { + // Keep waiting in these cases: + // expect(locator).conditionThatDoesNotMatch + // expect(locator).not.conditionThatDoesMatch + return continuePolling; + } + return { matches, received }; + }); + }, options.timeout).catch(e => { // Q: Why not throw upon isNonRetriableError(e) as in other places? // A: We want user to receive a friendly message containing the last intermediate result. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) @@ -1444,18 +1464,20 @@ export class Frame extends SdkObject { if (e instanceof TimeoutError) result.timedOut = true; return result; - } + }); } - private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) { - const selectorInFrame = selector ? await progress.race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined; + private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }, noAbort: boolean) { + // The first expect check, a.k.a. one-shot, always finishes - even when progress is aborted. + const race = (p: Promise) => noAbort ? p : progress.race(p); + const selectorInFrame = selector ? await race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined; const { frame, info } = selectorInFrame || { frame: this, info: undefined }; const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility'); - const context = await progress.race(frame._context(world)); - const injected = await progress.race(context.injectedScript()); + const context = await race(frame._context(world)); + const injected = await race(context.injectedScript()); - const { log, matches, received, missingReceived } = await progress.race(injected.evaluate(async (injected, { info, options, callId }) => { + const { log, matches, received, missingReceived } = await race(injected.evaluate(async (injected, { info, options, callId }) => { const elements = info ? injected.querySelectorAll(info.parsed, document) : []; if (callId) injected.markTargetElements(new Set(elements), callId); diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 4e4c8a0ac4f1e..fe680785720bd 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -15,7 +15,7 @@ */ import { TimeoutError } from './errors'; -import { assert } from '../utils'; +import { assert, monotonicTime } from '../utils'; import { ManualPromise } from '../utils/isomorphic/manualPromise'; import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation'; @@ -43,6 +43,10 @@ export interface Progress { raceWithCleanup(promise: Promise, cleanup: (result: T) => any): Promise; wait(timeout: number): Promise; metadata: CallMetadata; + + // Legacy lenient mode api only. To be removed. + legacyDisableTimeout(): void; + legacyEnableTimeout(): void; } export class ProgressController { @@ -93,6 +97,26 @@ export class ProgressController { this._state = 'running'; this.sdkObject.attribution.context?._activeProgressControllers.add(this); + const deadline = timeout ? Math.min(monotonicTime() + timeout, 2147483647) : 0; // 2^31-1 safe setTimeout in Node. + const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded.`); + + let timer: NodeJS.Timeout | undefined; + const startTimer = () => { + if (!deadline) + return; + const onTimeout = () => { + if (this._state === 'running') { + this._state = { error: timeoutError }; + this._forceAbortPromise.reject(timeoutError); + } + }; + const remaining = deadline - monotonicTime(); + if (remaining <= 0) + onTimeout(); + else + timer = setTimeout(onTimeout, remaining); + }; + const progress: Progress = { log: message => { if (this._state === 'running') @@ -128,18 +152,19 @@ export class ProgressController { const promise = new Promise(f => timer = setTimeout(f, timeout)); return progress.race(promise).finally(() => clearTimeout(timer)); }, + legacyDisableTimeout: () => { + if (this._strictMode) + return; + clearTimeout(timer); + }, + legacyEnableTimeout: () => { + if (this._strictMode) + return; + startTimer(); + }, }; - let timer: NodeJS.Timeout | undefined; - if (timeout) { - const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded.`); - timer = setTimeout(() => { - if (this._state === 'running') { - this._state = { error: timeoutError }; - this._forceAbortPromise.reject(timeoutError); - } - }, Math.min(timeout, 2147483647)); // 2^31-1 safe setTimeout in Node. - } + startTimer(); try { const promise = task(progress); diff --git a/packages/playwright-core/src/server/utils/network.ts b/packages/playwright-core/src/server/utils/network.ts index d12b72a1c389e..6054f60878618 100644 --- a/packages/playwright-core/src/server/utils/network.ts +++ b/packages/playwright-core/src/server/utils/network.ts @@ -39,9 +39,8 @@ export type HTTPRequestParams = { export const NET_DEFAULT_TIMEOUT = 30_000; export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.IncomingMessage) => void, onError: (error: Error) => void): { cancel(error: Error | undefined): void } { - const parsedUrl = url.parse(params.url); - let options: https.RequestOptions = { - ...parsedUrl, + const parsedUrl = new URL(params.url); + const options: https.RequestOptions = { agent: parsedUrl.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent, method: params.method || 'GET', headers: params.headers, @@ -51,19 +50,15 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco const proxyURL = getProxyForUrl(params.url); if (proxyURL) { - const parsedProxyURL = url.parse(proxyURL); + const parsedProxyURL = new URL(proxyURL); if (params.url.startsWith('http:')) { - options = { - path: parsedUrl.href, - host: parsedProxyURL.hostname, - port: parsedProxyURL.port, - headers: options.headers, - method: options.method - }; + parsedUrl.pathname = parsedUrl.href; + parsedUrl.host = parsedProxyURL.host; } else { - (parsedProxyURL as any).secureProxy = parsedProxyURL.protocol === 'https:'; - - options.agent = new HttpsProxyAgent(parsedProxyURL); + options.agent = new HttpsProxyAgent({ + ...convertURLtoLegacyUrl(parsedProxyURL), + secureProxy: parsedProxyURL.protocol === 'https:', + }); options.rejectUnauthorized = false; } } @@ -81,8 +76,8 @@ export function httpRequest(params: HTTPRequestParams, onResponse: (r: http.Inco } }; const request = options.protocol === 'https:' ? - https.request(options, requestCallback) : - http.request(options, requestCallback); + https.request(parsedUrl, options, requestCallback) : + http.request(parsedUrl, options, requestCallback); request.on('error', onError); if (params.socketTimeout !== undefined) { request.setTimeout(params.socketTimeout, () => { @@ -137,23 +132,27 @@ export function createProxyAgent(proxy?: ProxySettings, forUrl?: URL) { if (!/^\w+:\/\//.test(proxyServer)) proxyServer = 'http://' + proxyServer; - const proxyOpts = url.parse(proxyServer); + const proxyOpts = new URL(proxyServer); if (proxyOpts.protocol?.startsWith('socks')) { return new SocksProxyAgent({ host: proxyOpts.hostname, port: proxyOpts.port || undefined, }); } - if (proxy.username) - proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; + if (proxy.username) { + proxyOpts.username = proxy.username; + proxyOpts.password = proxy.password || ''; + } if (forUrl && ['ws:', 'wss:'].includes(forUrl.protocol)) { // Force CONNECT method for WebSockets. - return new HttpsProxyAgent(proxyOpts); + // TODO: switch to URL instance instead of legacy object once https-proxy-agent supports it. + return new HttpsProxyAgent(convertURLtoLegacyUrl(proxyOpts)); } // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method. - return new HttpsProxyAgent(proxyOpts); + // TODO: switch to URL instance instead of legacy object once https-proxy-agent supports it. + return new HttpsProxyAgent(convertURLtoLegacyUrl(proxyOpts)); } export function createHttpServer(requestListener?: (req: http.IncomingMessage, res: http.ServerResponse) => void): http.Server; @@ -226,3 +225,20 @@ function decorateServer(server: net.Server) { return close.call(server, callback); }; } + +function convertURLtoLegacyUrl(url: URL): url.Url { + return { + auth: url.username ? url.username + ':' + url.password : null, + hash: url.hash || null, + host: url.hostname ? url.hostname + ':' + url.port : null, + hostname: url.hostname || null, + href: url.href, + path: url.pathname + url.search, + pathname: url.pathname, + protocol: url.protocol, + search: url.search || null, + slashes: true, + port: url.port || null, + query: url.search.slice(1) || null, + }; +} diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 673dcd2612286..2075f076ddab0 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -154,6 +154,7 @@ export const methodMetainfo = new Map=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.54.0-next", + "zod-to-json-schema": "^3.24.4" + }, + "devDependencies": { + "@types/debug": "^4.1.7" + }, + "bin": { + "playwright-mdd": "cli.js" + } +} diff --git a/packages/playwright-mdd/src/context.ts b/packages/playwright-mdd/src/context.ts new file mode 100644 index 0000000000000..d998e51247c11 --- /dev/null +++ b/packages/playwright-mdd/src/context.ts @@ -0,0 +1,232 @@ +/** + * 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 debug from 'debug'; +import * as playwright from 'playwright'; + +import { callOnPageNoTrace, waitForCompletion } from './tools/utils'; +import { ManualPromise } from './manualPromise'; + +import type { ModalState, Tool, ToolActionResult } from './tools/tool'; + +type PendingAction = { + dialogShown: ManualPromise; +}; + +type PageEx = playwright.Page & { + _snapshotForAI: () => Promise; +}; + +const testDebug = debug('pw:mcp:test'); + +export class Context { + readonly browser: playwright.Browser; + readonly page: playwright.Page; + readonly tools: Tool[]; + private _modalStates: ModalState[] = []; + private _pendingAction: PendingAction | undefined; + private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; + + constructor(browser: playwright.Browser, page: playwright.Page, tools: Tool[]) { + this.browser = browser; + this.page = page; + this.tools = tools; + testDebug('create context'); + } + + static async create(tools: Tool[]): Promise { + const browser = await playwright.chromium.launch({ + headless: false, + }); + const context = await browser.newContext(); + const page = await context.newPage(); + return new Context(browser, page, tools); + } + + 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 run(tool: Tool, params: Record | undefined): Promise<{ content: string, code: 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 { + code, + 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()); + + return { + code, + content: result.join('\n'), + }; + } + + async title(): Promise { + 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 { + 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): Promise { + 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; + } +} diff --git a/packages/playwright-mdd/src/format.ts b/packages/playwright-mdd/src/format.ts new file mode 100644 index 0000000000000..a1fabbd97d125 --- /dev/null +++ b/packages/playwright-mdd/src/format.ts @@ -0,0 +1,53 @@ +/** + * 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); +} diff --git a/packages/playwright-mdd/src/loop.ts b/packages/playwright-mdd/src/loop.ts new file mode 100644 index 0000000000000..64d715d3b2e64 --- /dev/null +++ b/packages/playwright-mdd/src/loop.ts @@ -0,0 +1,108 @@ +/** + * 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 OpenAI from 'openai'; +import debug from 'debug'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Tool } from './tools/tool'; +import { Context } from './context'; + +/* eslint-disable no-console */ + +export async function runTasks(context: Context, tasks: string[]): Promise { + const openai = new OpenAI(); + const allCode: string[] = [ + `test('generated code', async ({ page }) => {`, + ]; + for (const task of tasks) { + const { taskCode } = await runTask(openai, context, task); + if (taskCode.length) + allCode.push('', ...taskCode.map(code => ` ${code}`)); + } + allCode.push('});'); + return allCode.join('\n'); +} + +async function runTask(openai: OpenAI, context: Context, task: string): Promise<{ taskCode: string[] }> { + console.log('Perform task:', task); + + const taskCode: string[] = [ + `// ${task}`, + ]; + + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'user', + content: `Peform following task: ${task}. Once the task is complete, call the "done" tool.` + } + ]; + + for (let iteration = 0; iteration < 5; ++iteration) { + debug('history')(messages); + const response = await openai.chat.completions.create({ + model: 'gpt-4.1', + 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); + + if (functionCall.name === 'done') + return { taskCode }; + + const tool = context.tools.find(tool => tool.schema.name === functionCall.name); + if (!tool) + throw new Error('Unknown tool: ' + functionCall.name); + + const { code, content } = await context.run(tool, JSON.parse(functionCall.arguments)); + taskCode.push(...code); + + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content, + }); + } + } + throw new Error('Failed to perform step, max attempts reached'); +} + +function asOpenAIDeclaration(tool: Tool): 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, + }, + }; +} diff --git a/packages/playwright-mdd/src/manualPromise.ts b/packages/playwright-mdd/src/manualPromise.ts new file mode 100644 index 0000000000000..a5034e05ece5f --- /dev/null +++ b/packages/playwright-mdd/src/manualPromise.ts @@ -0,0 +1,127 @@ +/** + * 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 extends Promise { + 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, 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(scopes: LongStandingScope[], promise: Promise): Promise { + return Promise.race(scopes.map(s => s.race(promise))); + } + + async race(promise: Promise | Promise[]): Promise { + return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; + } + + async safeRace(promise: Promise, defaultValue?: T): Promise { + return this._race([promise], true, defaultValue); + } + + private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { + const terminatePromise = new ManualPromise(); + 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'); +} diff --git a/packages/playwright-mdd/src/program.ts b/packages/playwright-mdd/src/program.ts new file mode 100644 index 0000000000000..aebfae0f124a5 --- /dev/null +++ b/packages/playwright-mdd/src/program.ts @@ -0,0 +1,50 @@ +/** + * 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 dotenv from 'dotenv'; +import { program } from 'commander'; + +import { runTasks } from './loop'; +import { Context } from './context'; +import { tools } from './tools'; + +/* eslint-disable no-console */ + +dotenv.config(); + +const packageJSON = require('../package.json'); + +program + .version('Version ' + packageJSON.version) + .name(packageJSON.name) + .action(async () => { + const context = await Context.create(tools); + const code = await runTasks(context, script); + console.log('Output code:'); + console.log('```javascript'); + console.log(code); + console.log('```'); + await context.close(); + }); + +const script = [ + 'Navigate to https://debs-obrien.github.io/playwright-movies-app', + 'Click search icon', + 'Type "Twister" in the search field and hit Enter', + 'Click on the link for the movie "Twisters"', +]; + +export { program }; diff --git a/packages/playwright-mdd/src/tools.ts b/packages/playwright-mdd/src/tools.ts new file mode 100644 index 0000000000000..e4b7c9a6ce117 --- /dev/null +++ b/packages/playwright-mdd/src/tools.ts @@ -0,0 +1,27 @@ +/** + * 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 snapshot from './tools/snapshot'; +import done from './tools/done'; +import navigate from './tools/navigate'; + +import type { Tool } from './tools/tool.js'; + +export const tools: Tool[] = [ + ...navigate, + ...snapshot, + ...done, +]; diff --git a/packages/playwright-mdd/src/tools/done.ts b/packages/playwright-mdd/src/tools/done.ts new file mode 100644 index 0000000000000..8818d4368d360 --- /dev/null +++ b/packages/playwright-mdd/src/tools/done.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 { 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, +]; diff --git a/packages/playwright-mdd/src/tools/navigate.ts b/packages/playwright-mdd/src/tools/navigate.ts new file mode 100644 index 0000000000000..cf6f695fef9c0 --- /dev/null +++ b/packages/playwright-mdd/src/tools/navigate.ts @@ -0,0 +1,88 @@ +/** + * 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, +]; diff --git a/packages/playwright-mdd/src/tools/snapshot.ts b/packages/playwright-mdd/src/tools/snapshot.ts new file mode 100644 index 0000000000000..e2a90ad8523d4 --- /dev/null +++ b/packages/playwright-mdd/src/tools/snapshot.ts @@ -0,0 +1,194 @@ +/** + * 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, + }; + }, +}); + +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)[] = []; + + 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, +]; diff --git a/packages/playwright-mdd/src/tools/tool.ts b/packages/playwright-mdd/src/tools/tool.ts new file mode 100644 index 0000000000000..9f840051eb587 --- /dev/null +++ b/packages/playwright-mdd/src/tools/tool.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 type { z } from 'zod'; +import type * as playwright from 'playwright-core'; +import type { Context } from '../context'; + +export type ToolSchema = { + name: string; + description: string; + inputSchema: Input; +}; + +type InputType = z.Schema; + +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; + captureSnapshot: boolean; + waitForNetwork: boolean; +}; + +export type Tool = { + schema: ToolSchema; + clearsModalState?: ModalState['type']; + handle: (context: Context, params: z.output) => Promise; +}; + +export function defineTool(tool: Tool): Tool { + return tool; +} diff --git a/packages/playwright-mdd/src/tools/utils.ts b/packages/playwright-mdd/src/tools/utils.ts new file mode 100644 index 0000000000000..d82095edc80a0 --- /dev/null +++ b/packages/playwright-mdd/src/tools/utils.ts @@ -0,0 +1,91 @@ +/** + * 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(context: Context, callback: () => Promise): Promise { + const requests = new Set(); + let frameNavigated = false; + let waitCallback: () => void = () => {}; + const waitBarrier = new Promise(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 { + 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(page: playwright.Page, callback: (page: playwright.Page) => Promise): Promise { + return await (page as any)._wrapApiCall(() => callback(page), { internal: true }); +} diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 874f645001ed7..c2f5f4540ce7e 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -44,7 +44,17 @@ function addTestCommand(program: Command) { const command = program.command('test [test-filter...]'); command.description('run tests with Playwright Test'); const options = testOptions.sort((a, b) => a[0].replace(/-/g, '').localeCompare(b[0].replace(/-/g, ''))); - options.forEach(([name, description]) => command.option(name, description)); + options.forEach(([name, { description, choices, preset }]) => { + const option = command.createOption(name, description); + if (choices) + option.choices(choices); + if (preset) + option.preset(preset); + // We don't set the default value here, because we want not specified options to + // fall back to the user config, which we haven't parsed yet. + command.addOption(option); + return command; + }); command.action(async (args, opts) => { try { await runTests(args, opts); @@ -269,12 +279,6 @@ async function mergeReports(reportDir: string | undefined, opts: { [key: string] } function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides { - let updateSnapshots: 'all' | 'changed' | 'missing' | 'none' | undefined; - if (['all', 'changed', 'missing', 'none'].includes(options.updateSnapshots)) - updateSnapshots = options.updateSnapshots; - else - updateSnapshots = 'updateSnapshots' in options ? 'changed' : undefined; - const overrides: ConfigCLIOverrides = { failOnFlakyTests: options.failOnFlakyTests ? true : undefined, forbidOnly: options.forbidOnly ? true : undefined, @@ -290,7 +294,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid timeout: options.timeout ? parseInt(options.timeout, 10) : undefined, tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined, ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined, - updateSnapshots, + updateSnapshots: options.updateSnapshots, updateSourceMethod: options.updateSourceMethod, workers: options.workers, }; @@ -315,8 +319,6 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid process.env.PWDEBUG = '1'; } if (!options.ui && options.trace) { - if (!kTraceModes.includes(options.trace)) - throw new Error(`Unsupported trace mode "${options.trace}", must be one of ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`); overrides.use = overrides.use || {}; overrides.use.trace = options.trace; } @@ -373,41 +375,41 @@ const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries // Note: update docs/src/test-cli-js.md when you update this, program is the source of truth. -const testOptions: [string, string][] = [ - /* deprecated */ ['--browser ', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`], - ['-c, --config ', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`], - ['--debug', `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`], - ['--fail-on-flaky-tests', `Fail if any test is flagged as flaky (default: false)`], - ['--forbid-only', `Fail if test.only is called (default: false)`], - ['--fully-parallel', `Run all tests in parallel (default: false)`], - ['--global-timeout ', `Maximum time this test suite can run in milliseconds (default: unlimited)`], - ['-g, --grep ', `Only run tests matching this regular expression (default: ".*")`], - ['-gv, --grep-invert ', `Only run tests that do not match this regular expression`], - ['--headed', `Run tests in headed browsers (default: headless)`], - ['--ignore-snapshots', `Ignore screenshot and snapshot expectations`], - ['--last-failed', `Only re-run the failures`], - ['--list', `Collect all the tests and report them, but do not run`], - ['--max-failures ', `Stop after the first N failures`], - ['--no-deps', 'Do not run project dependencies'], - ['--output ', `Folder for output artifacts (default: "test-results")`], - ['--only-changed [ref]', `Only run test files that have been changed between 'HEAD' and 'ref'. Defaults to running all uncommitted changes. Only supports Git.`], - ['--pass-with-no-tests', `Makes test run succeed even if no tests were found`], - ['--project ', `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)`], - ['--quiet', `Suppress stdio`], - ['--repeat-each ', `Run each test N times (default: 1)`], - ['--reporter ', `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${defaultReporter}")`], - ['--retries ', `Maximum retry count for flaky tests, zero for no retries (default: no retries)`], - ['--shard ', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`], - ['--timeout ', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], - ['--trace ', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], - ['--tsconfig ', `Path to a single tsconfig applicable to all imported files (default: look up tsconfig for each imported file separately)`], - ['--ui', `Run tests in interactive UI mode`], - ['--ui-host ', 'Host to serve UI on; specifying this option opens UI in a browser tab'], - ['--ui-port ', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'], - ['-u, --update-snapshots [mode]', `Update snapshots with actual results. Possible values are "all", "changed", "missing", and "none". Running tests without the flag defaults to "missing"; running tests with the flag but without a value defaults to "changed".`], - ['--update-source-method ', `Chooses the way source is updated. Possible values are 'overwrite', '3way' and 'patch'. Defaults to 'patch'`], - ['-j, --workers ', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`], - ['-x', `Stop after the first failure`], +const testOptions: [string, { description: string, choices?: string[], preset?: string }][] = [ + /* deprecated */ ['--browser ', { description: `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")` }], + ['-c, --config ', { description: `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"` }], + ['--debug', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options` }], + ['--fail-on-flaky-tests', { description: `Fail if any test is flagged as flaky (default: false)` }], + ['--forbid-only', { description: `Fail if test.only is called (default: false)` }], + ['--fully-parallel', { description: `Run all tests in parallel (default: false)` }], + ['--global-timeout ', { description: `Maximum time this test suite can run in milliseconds (default: unlimited)` }], + ['-g, --grep ', { description: `Only run tests matching this regular expression (default: ".*")` }], + ['--grep-invert ', { description: `Only run tests that do not match this regular expression` }], + ['--headed', { description: `Run tests in headed browsers (default: headless)` }], + ['--ignore-snapshots', { description: `Ignore screenshot and snapshot expectations` }], + ['--last-failed', { description: `Only re-run the failures` }], + ['--list', { description: `Collect all the tests and report them, but do not run` }], + ['--max-failures ', { description: `Stop after the first N failures` }], + ['--no-deps', { description: `Do not run project dependencies` }], + ['--output ', { description: `Folder for output artifacts (default: "test-results")` }], + ['--only-changed [ref]', { description: `Only run test files that have been changed between 'HEAD' and 'ref'. Defaults to running all uncommitted changes. Only supports Git.` }], + ['--pass-with-no-tests', { description: `Makes test run succeed even if no tests were found` }], + ['--project ', { description: `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)` }], + ['--quiet', { description: `Suppress stdio` }], + ['--repeat-each ', { description: `Run each test N times (default: 1)` }], + ['--reporter ', { description: `Reporter to use, comma-separated, can be ${builtInReporters.map(name => `"${name}"`).join(', ')} (default: "${defaultReporter}")` }], + ['--retries ', { description: `Maximum retry count for flaky tests, zero for no retries (default: no retries)` }], + ['--shard ', { description: `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"` }], + ['--timeout ', { description: `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})` }], + ['--trace ', { description: `Force tracing mode`, choices: kTraceModes as string[] }], + ['--tsconfig ', { description: `Path to a single tsconfig applicable to all imported files (default: look up tsconfig for each imported file separately)` }], + ['--ui', { description: `Run tests in interactive UI mode` }], + ['--ui-host ', { description: `Host to serve UI on; specifying this option opens UI in a browser tab` }], + ['--ui-port ', { description: `Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab` }], + ['-u, --update-snapshots [mode]', { description: `Update snapshots with actual results. Running tests without the flag defaults to "missing"`, choices: ['all', 'changed', 'missing', 'none'], preset: 'changed' }], + ['--update-source-method ', { description: `Chooses the way source is updated (default: "patch")`, choices: ['overwrite', '3way', 'patch'] }], + ['-j, --workers ', { description: `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)` }], + ['-x', { description: `Stop after the first failure` }], ]; addTestCommand(program); diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 297fa7849c351..1b6ff2f2ad98f 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -57,7 +57,6 @@ class HtmlReporter implements ReporterV2 { private _open: string | undefined; private _port: number | undefined; private _host: string | undefined; - private _title: string | undefined; private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined; private _topLevelErrors: api.TestError[] = []; @@ -78,13 +77,12 @@ class HtmlReporter implements ReporterV2 { } onBegin(suite: api.Suite) { - const { outputFolder, open, attachmentsBaseURL, host, port, title } = this._resolveOptions(); + const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions(); this._outputFolder = outputFolder; this._open = open; this._host = host; this._port = port; this._attachmentsBaseURL = attachmentsBaseURL; - this._title = title; const reportedWarnings = new Set(); for (const project of this.config.projects) { if (this._isSubdirectory(outputFolder, project.outputDir) || this._isSubdirectory(project.outputDir, outputFolder)) { @@ -104,7 +102,7 @@ class HtmlReporter implements ReporterV2 { this.suite = suite; } - _resolveOptions(): { outputFolder: string, open: HtmlReportOpenOption, attachmentsBaseURL: string, host: string | undefined, port: number | undefined, title: string | undefined } { + _resolveOptions(): { outputFolder: string, open: HtmlReportOpenOption, attachmentsBaseURL: string, host: string | undefined, port: number | undefined } { const outputFolder = reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', this._options.configDir, this._options.outputFolder); return { outputFolder, @@ -112,7 +110,6 @@ class HtmlReporter implements ReporterV2 { attachmentsBaseURL: process.env.PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL || this._options.attachmentsBaseURL || 'data/', host: process.env.PLAYWRIGHT_HTML_HOST || this._options.host, port: process.env.PLAYWRIGHT_HTML_PORT ? +process.env.PLAYWRIGHT_HTML_PORT : this._options.port, - title: process.env.PLAYWRIGHT_HTML_TITLE || this._options.title, }; } @@ -128,7 +125,7 @@ class HtmlReporter implements ReporterV2 { async onEnd(result: api.FullResult) { const projectSuites = this.suite.suites; await removeFolders([this._outputFolder]); - const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL, this._title); + const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL, process.env.PLAYWRIGHT_HTML_TITLE || this._options.title); this._buildResult = await builder.build(this.config.metadata, projectSuites, result, this._topLevelErrors); } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 3e052e3d5726e..0424cd47453e7 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2635,6 +2635,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { fill(params: FrameFillParams, metadata?: CallMetadata): Promise; focus(params: FrameFocusParams, metadata?: CallMetadata): Promise; frameElement(params?: FrameFrameElementParams, metadata?: CallMetadata): Promise; + generateLocatorString(params: FrameGenerateLocatorStringParams, metadata?: CallMetadata): Promise; highlight(params: FrameHighlightParams, metadata?: CallMetadata): Promise; getAttribute(params: FrameGetAttributeParams, metadata?: CallMetadata): Promise; goto(params: FrameGotoParams, metadata?: CallMetadata): Promise; @@ -2890,6 +2891,15 @@ export type FrameFrameElementOptions = {}; export type FrameFrameElementResult = { element: ElementHandleChannel, }; +export type FrameGenerateLocatorStringParams = { + selector: string, +}; +export type FrameGenerateLocatorStringOptions = { + +}; +export type FrameGenerateLocatorStringResult = { + value?: string, +}; export type FrameHighlightParams = { selector: string, }; @@ -3395,7 +3405,6 @@ export interface ElementHandleChannel extends ElementHandleEventTarget, JSHandle dispatchEvent(params: ElementHandleDispatchEventParams, metadata?: CallMetadata): Promise; fill(params: ElementHandleFillParams, metadata?: CallMetadata): Promise; focus(params?: ElementHandleFocusParams, metadata?: CallMetadata): Promise; - generateLocatorString(params?: ElementHandleGenerateLocatorStringParams, metadata?: CallMetadata): Promise; getAttribute(params: ElementHandleGetAttributeParams, metadata?: CallMetadata): Promise; hover(params: ElementHandleHoverParams, metadata?: CallMetadata): Promise; innerHTML(params?: ElementHandleInnerHTMLParams, metadata?: CallMetadata): Promise; @@ -3531,11 +3540,6 @@ export type ElementHandleFillResult = void; export type ElementHandleFocusParams = {}; export type ElementHandleFocusOptions = {}; export type ElementHandleFocusResult = void; -export type ElementHandleGenerateLocatorStringParams = {}; -export type ElementHandleGenerateLocatorStringOptions = {}; -export type ElementHandleGenerateLocatorStringResult = { - value?: string, -}; export type ElementHandleGetAttributeParams = { name: string, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index e9c765d045421..be56d9af827f1 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2289,6 +2289,13 @@ Frame: returns: element: ElementHandle + generateLocatorString: + internal: true + parameters: + selector: string + returns: + value: string? + highlight: internal: true parameters: @@ -2937,11 +2944,6 @@ ElementHandle: slowMo: true snapshot: true - generateLocatorString: - internal: true - returns: - value: string? - getAttribute: internal: true parameters: diff --git a/packages/recorder/tsconfig.json b/packages/recorder/tsconfig.json index 166ee1a010ca1..4defa85f64dd4 100644 --- a/packages/recorder/tsconfig.json +++ b/packages/recorder/tsconfig.json @@ -20,6 +20,7 @@ "@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"], "@protocol/*": ["../protocol/src/*"], "@recorder/*": ["../recorder/src/*"], + "@testIsomorphic/*": ["../playwright/src/isomorphic/*"], "@web/*": ["../web/src/*"], } }, diff --git a/packages/recorder/tsconfig.node.json b/packages/recorder/tsconfig.node.json index e993792cb12c9..a336f895aab52 100644 --- a/packages/recorder/tsconfig.node.json +++ b/packages/recorder/tsconfig.node.json @@ -2,7 +2,8 @@ "compilerOptions": { "composite": true, "module": "esnext", - "moduleResolution": "node" + "moduleResolution": "node", + "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } diff --git a/tests/config/testserver/index.ts b/tests/config/testserver/index.ts index 7538badf9de6c..91f8d8840fb12 100644 --- a/tests/config/testserver/index.ts +++ b/tests/config/testserver/index.ts @@ -20,7 +20,6 @@ import type http from 'http'; import mime from 'mime'; import type net from 'net'; import path from 'path'; -import url from 'url'; import util from 'util'; import type stream from 'stream'; import ws from 'ws'; @@ -90,7 +89,7 @@ export class TestServer { this._upgradeCallback({ doUpgrade, socket }); return; } - const pathname = url.parse(request.url!).path; + const pathname = new URL(request.url, 'http://localhost').pathname; if (pathname === '/ws-401') { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\nUnauthorized body'); socket.destroy(); @@ -218,10 +217,11 @@ export class TestServer { }); request.on('end', () => resolve(Buffer.concat(chunks))); }); - const path = url.parse(request.url!).path; - this.debugServer(`request ${request.method} ${path}`); - if (this._auths.has(path)) { - const auth = this._auths.get(path)!; + const url = new URL(request.url, 'http://localhost'); + const pathWithSearch = url.pathname + url.search; + this.debugServer(`request ${request.method} ${pathWithSearch}`); + if (this._auths.has(pathWithSearch)) { + const auth = this._auths.get(pathWithSearch)!; const credentials = Buffer.from((request.headers.authorization || '').split(' ')[1] || '', 'base64').toString(); this.debugServer(`request credentials ${credentials}`); this.debugServer(`actual credentials ${auth.username}:${auth.password}`); @@ -233,11 +233,11 @@ export class TestServer { } } // Notify request subscriber. - if (this._requestSubscribers.has(path)) { - this._requestSubscribers.get(path)![fulfillSymbol].call(null, request); - this._requestSubscribers.delete(path); + if (this._requestSubscribers.has(pathWithSearch)) { + this._requestSubscribers.get(pathWithSearch)![fulfillSymbol].call(null, request); + this._requestSubscribers.delete(pathWithSearch); } - const handler = this._routes.get(path); + const handler = this._routes.get(pathWithSearch); if (handler) handler.call(null, request, response); else @@ -251,7 +251,7 @@ export class TestServer { } private async _serveFile(request: http.IncomingMessage, response: http.ServerResponse, filePath?: string): Promise { - let pathName = url.parse(request.url!).path; + let pathName = new URL(request.url, 'http://localhost').pathname; if (!filePath) { if (pathName === '/') pathName = '/index.html'; diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index 09849077b5985..9360ce8a50170 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -107,6 +107,8 @@ export const test = _test const npmLines = [ `registry = ${registry.url()}/`, `cache = ${testInfo.outputPath('npm_cache')}`, + // Required after https://github.com/npm/cli/pull/8185. + 'replace-registry-host=never', ]; if (!allowGlobalInstall) { yarnLines.push(`prefix "${testInfo.outputPath('npm_global')}"`); diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index 9f909945a076d..ebbb22adb2230 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -38,7 +38,6 @@ test('install command should work', async ({ exec, checkInstalledSoftwareOnDisk await test.step('playwright install --list', async () => { const result = await exec('npx playwright install --list'); - console.log('result', result); expect.soft(result).toMatch(/Playwright version: \d+\.\d+/); expect.soft(result).toMatch(/chromium-\d+/); expect.soft(result).toMatch(/chromium_headless_shell-\d+/); diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index 35d591bf9fc8e..3d12cd086fee7 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -101,6 +101,10 @@ it('should stitch all frame snapshots', async ({ page, server }) => { const locator = await (page.locator('aria-ref=f2e2').describe('foo bar') as any)._generateLocatorString(); expect(locator).toBe(`locator('iframe[name=\"2frames\"]').contentFrame().locator('iframe[name=\"uno\"]').contentFrame().getByText('Hi, I\\'m frame')`); } + { + const error = await (page.locator('aria-ref=e1000') as any)._generateLocatorString().catch(e => e); + expect(error.message).toContain(`No element matching locator('aria-ref=e1000')`); + } }); it('should not generate refs for elements with pointer-events:none', async ({ page }) => { diff --git a/tests/page/page-select-option.spec.ts b/tests/page/page-select-option.spec.ts index 160996446d61a..0355b4592bbd8 100644 --- a/tests/page/page-select-option.spec.ts +++ b/tests/page/page-select-option.spec.ts @@ -320,7 +320,7 @@ it('input event.composed should be true and cross shadow dom boundary', async ({ expect(await page.evaluate(() => window['firedBodyEvents'])).toEqual(['input:true']); }); -it('should wait for option to be enabled', async ({ page }) => { +it('should wait for select to be enabled', async ({ page }) => { await page.setContent(` + + + + + + `); + + const error = await page.locator('select').selectOption('two', { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('option being selected is not enabled'); + + const selectPromise = page.locator('select').selectOption('two'); + await new Promise(f => setTimeout(f, 1000)); + await page.evaluate(() => (window as any).hydrate()); + await selectPromise; + expect(await page.evaluate(() => window['result'])).toEqual('two'); + await expect(page.locator('select')).toHaveValue('two'); +}); + +it('should wait for optgroup to be enabled', async ({ page }) => { + await page.setContent(` + + + + `); + + const error = await page.locator('select').selectOption('two', { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('option being selected is not enabled'); + + const selectPromise = page.locator('select').selectOption('two'); + await new Promise(f => setTimeout(f, 1000)); + await page.evaluate(() => (window as any).hydrate()); + await selectPromise; + expect(await page.evaluate(() => window['result'])).toEqual('two'); + await expect(page.locator('select')).toHaveValue('two'); +}); + it('should wait for select to be swapped', async ({ page }) => { await page.setContent(` + + + + + + + + `); expect(await page.locator(`role=button[disabled]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ ``, @@ -241,6 +250,18 @@ test('should support disabled', async ({ page }) => { ``, ``, ]); + expect(await page.getByRole('option', { disabled: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ]); + expect(await page.getByRole('option', { disabled: false }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.getByRole('option').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ]); }); test('should inherit disabled from the ancestor', async ({ page }) => { diff --git a/tests/playwright-test/babel.spec.ts b/tests/playwright-test/babel.spec.ts index 340fda91ad584..0d7572de5ba1a 100644 --- a/tests/playwright-test/babel.spec.ts +++ b/tests/playwright-test/babel.spec.ts @@ -145,11 +145,15 @@ test('should not transform external', async ({ runInlineTest }) => { 'a.spec.ts': ` const { test, expect, Page } = require('@playwright/test'); let page: Page; - test('succeeds', () => {}); + enum MyEnum { Value = 'value' } + + test('succeeds', () => { + expect(MyEnum.Value).toBe('value'); + }); ` }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`SyntaxError: Unexpected token ':'`); + expect(result.output).toMatch(/(SyntaxError: Unexpected token ':')|(SyntaxError: TypeScript enum is not supported)/); }); for (const type of ['module', undefined]) { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 4e9547e45b12b..6b99892b026a9 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -481,6 +481,40 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(anchorLocator.nth(1)).toHaveAttribute('href', 'http://microsoft.com'); }); + test('should allow setting title from env in global teardown', async ({ runInlineTest, page, showReport }, testInfo) => { + test.skip(useIntermediateMergeReport, 'env vars are not available in merge report'); + + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + globalTeardown: './global-teardown.js', + }; + `, + 'omega-star.test.js': ` + import { test, expect } from '@playwright/test'; + import fs from 'fs/promises'; + test('version check', async ({}, testInfo) => { + const apiVersion = 'abcde'; + await fs.writeFile(testInfo.outputPath('omega_star_version'), apiVersion); + expect(2).toEqual(2); + }); + `, + 'global-teardown.js': ` + import fs from 'fs/promises'; + import path from 'path'; + export default async (config) => { + const apiVersion = await fs.readFile(path.join('test-results', 'omega-star-version-check', 'omega_star_version'), 'utf-8'); + process.env.PLAYWRIGHT_HTML_TITLE = 'Omega Star Test Suite (Version: ' + apiVersion + ')'; + }; + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + await showReport(); + await expect(page.locator('.header-title')).toHaveText('Omega Star Test Suite (Version: abcde)'); + }); + test('should include stdio', async ({ runInlineTest, page, showReport }) => { const result = await runInlineTest({ 'a.test.js': ` diff --git a/tests/third_party/proxy/index.ts b/tests/third_party/proxy/index.ts index e3faaec65741d..b3b4c450f2d65 100644 --- a/tests/third_party/proxy/index.ts +++ b/tests/third_party/proxy/index.ts @@ -1,6 +1,5 @@ import assert from 'assert'; import * as net from 'net'; -import * as url from 'url'; import * as http from 'http'; import * as os from 'os'; import { pipeline } from 'stream/promises'; @@ -101,7 +100,7 @@ async function onrequest( } socket.resume(); - const parsed = url.parse(req.url || '/'); + const parsed = new URL(req.url, 'http://localhost'); // setup outbound proxy request HTTP headers const headers: http.OutgoingHttpHeaders = {}; @@ -197,8 +196,7 @@ async function onrequest( } let gotResponse = false; - const proxyReq = http.request({ - ...parsed, + const proxyReq = http.request(parsed, { method: req.method, headers, localAddress: this.localAddress, diff --git a/utils/doclint/generateFullConfigDoc.js b/utils/doclint/generateFullConfigDoc.js deleted file mode 100644 index 8c00f7f8a2767..0000000000000 --- a/utils/doclint/generateFullConfigDoc.js +++ /dev/null @@ -1,121 +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. - */ - -// @ts-check - -const path = require('path'); -const fs = require('fs'); -const PROJECT_DIR = path.join(__dirname, '..', '..'); - -function generateFullConfigClass(fromClassName, toClassName, allowList) { - const allowedNames = new Set(allowList); - - const content = fs.readFileSync(path.join(PROJECT_DIR, `docs/src/test-api/class-${fromClassName.toLowerCase()}.md`)).toString(); - let sections = content.split('\n## '); - sections = filterAllowedSections(sections, allowedNames); - if (allowedNames.size) - console.log(`Undocumented properties for ${fromClassName}:\n ${[...allowedNames].join('\n ')}`); - sections = changeClassName(sections, fromClassName, toClassName); - sections = replacePropertyDescriptions(sections, fromClassName); - const fullconfig = sections.join('\n## '); - fs.writeFileSync(path.join(PROJECT_DIR, `docs/src/test-api/class-${toClassName.toLowerCase()}.md`), fullconfig); -} - -function propertyNameFromSection(section) { - section = section.split('\n')[0]; - const match = /\.(\w+)/.exec(section); - if (!match) - return null; - return match[1]; -} - -function filterAllowedSections(sections, allowedNames) { - return sections.filter(section => { - section = section.split('\n')[0]; - const name = propertyNameFromSection(section); - if (!name) - return true; - return allowedNames.delete(name); - }); -} - -function changeClassName(sections, from, to) { - return sections.map(section => { - const lines = section.split('\n'); - lines[0] = lines[0].replace(from, to); - return lines.join('\n'); - }); -} - -function replacePropertyDescriptions(sections, configClassName) { - return sections.map(section => { - const parts = section.split('\n\n'); - section = parts[0]; - const name = propertyNameFromSection(section); - if (!name) - return `${section}\n`; - return `${section}\n\nSee [\`property: ${configClassName}.${name}\`].\n`; - }); -} - -function generateFullConfig() { - generateFullConfigClass('TestConfig', 'FullConfig', [ - 'forbidOnly', - 'fullyParallel', - 'globalSetup', - 'globalTeardown', - 'globalTimeout', - 'grep', - 'grepInvert', - 'maxFailures', - 'metadata', - 'version', - 'preserveOutput', - 'projects', - 'reporter', - 'reportSlowTests', - 'rootDir', - 'quiet', - 'shard', - 'updateSnapshots', - 'workers', - 'webServer', - 'configFile', - ]); -} - -function generateFullProject() { - generateFullConfigClass('TestProject', 'FullProject', [ - 'grep', - 'grepInvert', - 'metadata', - 'name', - 'dependencies', - 'snapshotDir', - 'outputDir', - 'repeatEach', - 'retries', - 'teardown', - 'testDir', - 'testIgnore', - 'testMatch', - 'timeout', - 'use', - ]); -} - -generateFullConfig(); -generateFullProject(); diff --git a/utils/workspace.js b/utils/workspace.js index 5331dc3335ef7..928c61a0a0405 100755 --- a/utils/workspace.js +++ b/utils/workspace.js @@ -219,6 +219,11 @@ 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) {