From 2934858481d267df729eecb206ce45d497958015 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 23 Jul 2026 13:35:23 +0200 Subject: [PATCH 1/7] fix: correct pending navigation log spacing (#41949) Copilot-Session: c8568082-cd3f-4aa2-a7fa-15546d4738ff --- packages/playwright-core/src/server/page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 6204b2a3dbea2..0baec6bc59f09 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -553,7 +553,7 @@ export class Page extends SdkObject { if (!mainFrame || !mainFrame.pendingDocument()) return; const url = mainFrame.pendingDocument()?.request?.url(); - const toUrl = url ? `" ${trimStringWithEllipsis(url, 200)}"` : ''; + const toUrl = url ? ` "${trimStringWithEllipsis(url, 200)}"` : ''; progress.log(` waiting for${toUrl} navigation to finish...`); await helper.waitForEvent(progress, mainFrame, frames.Frame.Events.InternalNavigation, (e: frames.NavigationEvent) => { if (!e.isPublic) From 89efc223b8b12f3f333820aa3e843710936ad147 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 23 Jul 2026 14:49:42 +0100 Subject: [PATCH 2/7] chore: mark v1.63.0-next (#41952) --- package-lock.json | 58 +++++++++---------- package.json | 2 +- .../playwright-browser-chromium/package.json | 4 +- .../playwright-browser-firefox/package.json | 4 +- .../playwright-browser-webkit/package.json | 4 +- packages/playwright-chromium/package.json | 4 +- packages/playwright-client/package.json | 2 +- packages/playwright-core/package.json | 2 +- packages/playwright-ct-core/package.json | 6 +- packages/playwright-ct-react/package.json | 4 +- packages/playwright-ct-react17/package.json | 4 +- packages/playwright-ct-vue/package.json | 4 +- packages/playwright-firefox/package.json | 4 +- packages/playwright-test/package.json | 4 +- packages/playwright-webkit/package.json | 4 +- packages/playwright/package.json | 4 +- 16 files changed, 57 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 035d7711e3584..6c2121a1261b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playwright-internal", - "version": "1.62.0-next", + "version": "1.63.0-next", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playwright-internal", - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -9750,10 +9750,10 @@ "version": "0.0.0" }, "packages/playwright": { - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "bin": { "playwright": "cli.js" @@ -9767,11 +9767,11 @@ }, "packages/playwright-browser-chromium": { "name": "@playwright/browser-chromium", - "version": "1.62.0-next", + "version": "1.63.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "engines": { "node": ">=20" @@ -9779,11 +9779,11 @@ }, "packages/playwright-browser-firefox": { "name": "@playwright/browser-firefox", - "version": "1.62.0-next", + "version": "1.63.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "engines": { "node": ">=20" @@ -9791,22 +9791,22 @@ }, "packages/playwright-browser-webkit": { "name": "@playwright/browser-webkit", - "version": "1.62.0-next", + "version": "1.63.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "engines": { "node": ">=20" } }, "packages/playwright-chromium": { - "version": "1.62.0-next", + "version": "1.63.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "bin": { "playwright": "cli.js" @@ -9820,14 +9820,14 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "engines": { "node": ">=20" } }, "packages/playwright-core": { - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -9838,11 +9838,11 @@ }, "packages/playwright-ct-core": { "name": "@playwright/experimental-ct-core", - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.0-next", - "playwright-core": "1.62.0-next", + "playwright": "1.63.0-next", + "playwright-core": "1.63.0-next", "vite": "^8.1.0" }, "engines": { @@ -9851,10 +9851,10 @@ }, "packages/playwright-ct-react": { "name": "@playwright/experimental-ct-react", - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.62.0-next", + "@playwright/experimental-ct-core": "1.63.0-next", "@vitejs/plugin-react": "^6.0.3" }, "bin": { @@ -9866,10 +9866,10 @@ }, "packages/playwright-ct-react17": { "name": "@playwright/experimental-ct-react17", - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.62.0-next", + "@playwright/experimental-ct-core": "1.63.0-next", "@vitejs/plugin-react": "^6.0.3" }, "bin": { @@ -9881,10 +9881,10 @@ }, "packages/playwright-ct-vue": { "name": "@playwright/experimental-ct-vue", - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.62.0-next", + "@playwright/experimental-ct-core": "1.63.0-next", "@vitejs/plugin-vue": "^6.0.7" }, "bin": { @@ -9895,11 +9895,11 @@ } }, "packages/playwright-firefox": { - "version": "1.62.0-next", + "version": "1.63.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "bin": { "playwright": "cli.js" @@ -9910,10 +9910,10 @@ }, "packages/playwright-test": { "name": "@playwright/test", - "version": "1.62.0-next", + "version": "1.63.0-next", "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.0-next" + "playwright": "1.63.0-next" }, "bin": { "playwright": "cli.js" @@ -9923,11 +9923,11 @@ } }, "packages/playwright-webkit": { - "version": "1.62.0-next", + "version": "1.63.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "bin": { "playwright": "cli.js" diff --git a/package.json b/package.json index ac8a8bfebb4d3..980944675c659 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "playwright-internal", "private": true, - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", diff --git a/packages/playwright-browser-chromium/package.json b/packages/playwright-browser-chromium/package.json index 8c00195d97b0e..f02987af85442 100644 --- a/packages/playwright-browser-chromium/package.json +++ b/packages/playwright-browser-chromium/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-chromium", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright package that automatically installs Chromium", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright-browser-firefox/package.json b/packages/playwright-browser-firefox/package.json index 82ed459eb3f09..75e4590fb01b7 100644 --- a/packages/playwright-browser-firefox/package.json +++ b/packages/playwright-browser-firefox/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-firefox", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright package that automatically installs Firefox", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright-browser-webkit/package.json b/packages/playwright-browser-webkit/package.json index d267aba72d8b9..91dc074ee2c0d 100644 --- a/packages/playwright-browser-webkit/package.json +++ b/packages/playwright-browser-webkit/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-webkit", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright package that automatically installs WebKit", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright-chromium/package.json b/packages/playwright-chromium/package.json index 14672eaee109a..14fc536cfb985 100644 --- a/packages/playwright-chromium/package.json +++ b/packages/playwright-chromium/package.json @@ -1,6 +1,6 @@ { "name": "playwright-chromium", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate Chromium", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright-client/package.json b/packages/playwright-client/package.json index eabbce06c3499..7e47e9f487f13 100644 --- a/packages/playwright-client/package.json +++ b/packages/playwright-client/package.json @@ -24,6 +24,6 @@ "./package.json": "./package.json" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index b7eb7522a41a4..bd87066d74c85 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -1,6 +1,6 @@ { "name": "playwright-core", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", diff --git a/packages/playwright-ct-core/package.json b/packages/playwright-ct-core/package.json index 36a7d9beec49b..4a008df6c819c 100644 --- a/packages/playwright-ct-core/package.json +++ b/packages/playwright-ct-core/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-core", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright Component Testing Helpers", "repository": { "type": "git", @@ -26,8 +26,8 @@ } }, "dependencies": { - "playwright-core": "1.62.0-next", + "playwright-core": "1.63.0-next", "vite": "^8.1.0", - "playwright": "1.62.0-next" + "playwright": "1.63.0-next" } } diff --git a/packages/playwright-ct-react/package.json b/packages/playwright-ct-react/package.json index c66ad66a86b7c..32e224b802f22 100644 --- a/packages/playwright-ct-react/package.json +++ b/packages/playwright-ct-react/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-react", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright Component Testing for React", "repository": { "type": "git", @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@playwright/experimental-ct-core": "1.62.0-next", + "@playwright/experimental-ct-core": "1.63.0-next", "@vitejs/plugin-react": "^6.0.3" }, "bin": { diff --git a/packages/playwright-ct-react17/package.json b/packages/playwright-ct-react17/package.json index cc9bdf53a8a55..9390e961a032c 100644 --- a/packages/playwright-ct-react17/package.json +++ b/packages/playwright-ct-react17/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-react17", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright Component Testing for React", "repository": { "type": "git", @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@playwright/experimental-ct-core": "1.62.0-next", + "@playwright/experimental-ct-core": "1.63.0-next", "@vitejs/plugin-react": "^6.0.3" }, "bin": { diff --git a/packages/playwright-ct-vue/package.json b/packages/playwright-ct-vue/package.json index 1ae537b648797..d4c42b49e04a7 100644 --- a/packages/playwright-ct-vue/package.json +++ b/packages/playwright-ct-vue/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-vue", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "Playwright Component Testing for Vue", "repository": { "type": "git", @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@playwright/experimental-ct-core": "1.62.0-next", + "@playwright/experimental-ct-core": "1.63.0-next", "@vitejs/plugin-vue": "^6.0.7" }, "bin": { diff --git a/packages/playwright-firefox/package.json b/packages/playwright-firefox/package.json index f31cfe372a27b..26a3a4fd348b7 100644 --- a/packages/playwright-firefox/package.json +++ b/packages/playwright-firefox/package.json @@ -1,6 +1,6 @@ { "name": "playwright-firefox", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate Firefox", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright-test/package.json b/packages/playwright-test/package.json index 225725e3c7b36..11bc7700f4e65 100644 --- a/packages/playwright-test/package.json +++ b/packages/playwright-test/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/test", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -30,6 +30,6 @@ }, "scripts": {}, "dependencies": { - "playwright": "1.62.0-next" + "playwright": "1.63.0-next" } } diff --git a/packages/playwright-webkit/package.json b/packages/playwright-webkit/package.json index 0b1322c59dacd..cefb60a66f1f3 100644 --- a/packages/playwright-webkit/package.json +++ b/packages/playwright-webkit/package.json @@ -1,6 +1,6 @@ { "name": "playwright-webkit", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate WebKit", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" } } diff --git a/packages/playwright/package.json b/packages/playwright/package.json index c4c744f43ea36..e82604319f86f 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -1,6 +1,6 @@ { "name": "playwright", - "version": "1.62.0-next", + "version": "1.63.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -50,7 +50,7 @@ }, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0-next" + "playwright-core": "1.63.0-next" }, "optionalDependencies": { "fsevents": "2.3.2" From 1299d0f6c421393d51c1a1e95f48b0a8b6ef1226 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 23 Jul 2026 14:51:38 +0100 Subject: [PATCH 3/7] feat(recorder): record actions by letting page events through naturally (#41902) --- .../injected/src/recorder/pollingRecorder.ts | 12 +- packages/injected/src/recorder/recorder.ts | 249 ++++++++---------- packages/isomorphic/codegen/actions.d.ts | 2 +- .../playwright-core/src/server/recorder.ts | 25 +- .../src/server/recorder/recorderRunner.ts | 105 +------- .../recorder/recorderSignalProcessor.ts | 70 ++++- .../src/server/recorder/recorderUtils.ts | 6 - tests/library/debug-controller.spec.ts | 2 +- tests/library/inspector/cli-codegen-1.spec.ts | 26 ++ tests/library/inspector/cli-codegen-3.spec.ts | 67 ++++- tests/library/inspector/recorder-api.spec.ts | 9 +- 11 files changed, 283 insertions(+), 290 deletions(-) diff --git a/packages/injected/src/recorder/pollingRecorder.ts b/packages/injected/src/recorder/pollingRecorder.ts index c3da732fde8c0..bc8abb956ce21 100644 --- a/packages/injected/src/recorder/pollingRecorder.ts +++ b/packages/injected/src/recorder/pollingRecorder.ts @@ -22,8 +22,8 @@ import type * as actions from '@isomorphic/codegen/actions'; import type { ElementInfo, Mode, OverlayState, UIState } from '@recorder/recorderTypes'; interface Embedder { - __pw_recorderPerformAction(action: actions.PerformOnRecordAction, preconditionSelector?: string): Promise; - __pw_recorderRecordAction(action: actions.Action): Promise; + __pw_recorderPerformAction(action: actions.PerformableAction): Promise; + __pw_recorderRecordAction(action: actions.Action, preconditionSelector?: string): Promise; __pw_recorderState(): Promise; __pw_recorderElementPicked(element: { selector: string, ariaSnapshot?: string }): Promise; __pw_recorderSetMode(mode: Mode): Promise; @@ -76,12 +76,12 @@ export class PollingRecorder implements RecorderDelegate { this._pollRecorderModeTimer = this._recorder.injectedScript.utils.builtins.setTimeout(() => this._pollRecorderMode(), pollPeriod); } - async performAction(action: actions.PerformOnRecordAction, preconditionSelector?: string) { - await this._embedder.__pw_recorderPerformAction(action, preconditionSelector); + async performAction(action: actions.PerformableAction) { + await this._embedder.__pw_recorderPerformAction(action); } - async recordAction(action: actions.Action): Promise { - await this._embedder.__pw_recorderRecordAction(action); + async recordAction(action: actions.Action, preconditionSelector?: string): Promise { + await this._embedder.__pw_recorderRecordAction(action, preconditionSelector); } async elementPicked(elementInfo: ElementInfo): Promise { diff --git a/packages/injected/src/recorder/recorder.ts b/packages/injected/src/recorder/recorder.ts index 91f146ea1ab61..5fa7719cdbc65 100644 --- a/packages/injected/src/recorder/recorder.ts +++ b/packages/injected/src/recorder/recorder.ts @@ -35,8 +35,8 @@ const HighlightColors = { }; export interface RecorderDelegate { - performAction?(action: actions.PerformOnRecordAction, preconditionSelector?: string): Promise; - recordAction?(action: actions.Action): Promise; + performAction?(action: actions.PerformableAction): Promise; + recordAction?(action: actions.Action, preconditionSelector?: string): Promise; elementPicked?(elementInfo: ElementInfo): Promise; setMode?(mode: Mode): Promise; setOverlayState?(state: OverlayState): Promise; @@ -189,12 +189,10 @@ class InspectTool implements RecorderTool { class RecordActionTool implements RecorderTool { private _recorder: Recorder; - private _performingActions: Set; + private _performingActions: Set; private _hoveredModel: HighlightModelWithSelector | null = null; private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModelWithSelector | null = null; - private _expectProgrammaticKeyUp = false; - private _pendingClickAction: { action: actions.ClickAction, timeout: number } | undefined; private _observer: MutationObserver | null = null; private _dialog: Dialog; @@ -232,7 +230,6 @@ class RecordActionTool implements RecorderTool { this._hoveredModel = null; this._hoveredElement = null; this._activeModel = null; - this._expectProgrammaticKeyUp = false; this._dialog.close(); } @@ -252,44 +249,43 @@ class RecordActionTool implements RecorderTool { return; if (this._shouldIgnoreMouseEvent(event)) return; - if (this._actionInProgress(event)) - return; - if (this._consumedDueToNoModel(event, this._hoveredModel)) - return; if (event.button === 2 && event.type === 'auxclick') { - this._showActionListDialog(this._hoveredModel!, event); + // A right-click we are performing on behalf of the dialog is recorded via onContextMenu. + if (!this._performingActions.size) + this._showActionListDialog(event); return; } - const checkbox = asCheckbox(this._recorder.deepEventTarget(event)); + // Keyboard-activated clicks, e.g. Enter on a button or Space on a checkbox, + // come with zero detail and are recorded in onKeyDown instead. + if (event.detail === 0) + return; + + const target = this._recorder.deepEventTarget(event); + const checkbox = asCheckbox(target); if (checkbox && event.detail === 1) { - // Interestingly, inputElement.checked is reversed inside this event handler. - this._performAction({ + // Note: inputElement.checked already reflects the new state inside this event handler. + this._recordAction({ name: checkbox.checked ? 'check' : 'uncheck', - selector: this._hoveredModel!.selector, + selector: this._hoveredModel?.selector ?? this._selectorForElement(target), signals: [], - }); + }, { autoExpect: true }); return; } - this._cancelPendingClickAction(); - - // Stall click in case we are observing double-click. - if (event.detail === 1) { - this._pendingClickAction = { - action: { - name: 'click', - selector: this._hoveredModel!.selector, - position: positionForEvent(event), - signals: [], - button: buttonForEvent(event), - modifiers: modifiersForEvent(event), - clickCount: event.detail - }, - timeout: this._recorder.injectedScript.utils.builtins.setTimeout(() => this._commitPendingClickAction(), 200) - }; - } + // Only single clicks are recorded here; double clicks are recorded in onDblClick. + if (event.detail !== 1) + return; + this._recordAction({ + name: 'click', + selector: this._hoveredModel?.selector ?? this._selectorForElement(target), + position: positionForEvent(event), + signals: [], + button: buttonForEvent(event), + modifiers: modifiersForEvent(event), + clickCount: event.detail + }, { autoExpect: true }); } onDblClick(event: MouseEvent) { @@ -299,35 +295,17 @@ class RecordActionTool implements RecorderTool { return; if (this._shouldIgnoreMouseEvent(event)) return; - // Only allow double click dispatch while action is in progress. - if (this._actionInProgress(event)) - return; - if (this._consumedDueToNoModel(event, this._hoveredModel)) - return; - - this._cancelPendingClickAction(); - this._performAction({ + const target = this._recorder.deepEventTarget(event); + this._recordAction({ name: 'click', - selector: this._hoveredModel!.selector, + selector: this._hoveredModel?.selector ?? this._selectorForElement(target), position: positionForEvent(event), signals: [], button: buttonForEvent(event), modifiers: modifiersForEvent(event), clickCount: event.detail - }); - } - - private _commitPendingClickAction() { - if (this._pendingClickAction) - this._performAction(this._pendingClickAction.action); - this._cancelPendingClickAction(); - } - - private _cancelPendingClickAction() { - if (this._pendingClickAction) - this._recorder.injectedScript.utils.builtins.clearTimeout(this._pendingClickAction.timeout); - this._pendingClickAction = undefined; + }, { autoExpect: true }); } onContextMenu(event: MouseEvent) { @@ -339,11 +317,21 @@ class RecordActionTool implements RecorderTool { } if (this._shouldIgnoreMouseEvent(event)) return; - if (this._actionInProgress(event)) - return; - if (this._consumedDueToNoModel(event, this._hoveredModel)) + if (this._performingActions.size) { + // The dialog is performing a right-click for us; record it naturally instead of reopening the dialog. + const target = this._recorder.deepEventTarget(event); + this._recordAction({ + name: 'click', + selector: this._hoveredModel?.selector ?? this._selectorForElement(target), + position: positionForEvent(event), + signals: [], + button: 'right', + modifiers: modifiersForEvent(event), + clickCount: 1, + }, { autoExpect: true }); return; - this._showActionListDialog(this._hoveredModel!, event); + } + this._showActionListDialog(event); } onPointerDown(event: PointerEvent) { @@ -351,7 +339,7 @@ class RecordActionTool implements RecorderTool { return; if (this._shouldIgnoreMouseEvent(event)) return; - this._consumeWhenAboutToPerform(event); + this._consumeRightButtonEvent(event); } onPointerUp(event: PointerEvent) { @@ -359,7 +347,7 @@ class RecordActionTool implements RecorderTool { return; if (this._shouldIgnoreMouseEvent(event)) return; - this._consumeWhenAboutToPerform(event); + this._consumeRightButtonEvent(event); } onMouseDown(event: MouseEvent) { @@ -367,7 +355,7 @@ class RecordActionTool implements RecorderTool { return; if (this._shouldIgnoreMouseEvent(event)) return; - this._consumeWhenAboutToPerform(event); + this._consumeRightButtonEvent(event); this._activeModel = this._hoveredModel; } @@ -376,7 +364,7 @@ class RecordActionTool implements RecorderTool { return; if (this._shouldIgnoreMouseEvent(event)) return; - this._consumeWhenAboutToPerform(event); + this._consumeRightButtonEvent(event); } onMouseMove(event: MouseEvent) { @@ -415,9 +403,9 @@ class RecordActionTool implements RecorderTool { // When the file input is hidden and triggered by another element (e.g. a button with // onclick="input.click()"), the hover model points to the trigger, not the input. // Derive the selector from the actual target element in that case. - const selector = target === this._hoveredElement - ? this._hoveredModel!.selector - : this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName }).selector; + const selector = target === this._hoveredElement && this._hoveredModel + ? this._hoveredModel.selector + : this._selectorForElement(target); this._recordAction({ name: 'setInputFiles', selector, @@ -431,7 +419,7 @@ class RecordActionTool implements RecorderTool { this._recordAction({ name: 'fill', // must use hoveredModel instead of activeModel for it to work in webkit - selector: this._hoveredModel!.selector, + selector: this._hoveredModel?.selector ?? this._selectorForElement(target), signals: [], text: target.value, }); @@ -440,16 +428,13 @@ class RecordActionTool implements RecorderTool { if (['INPUT', 'TEXTAREA'].includes(target.nodeName) || target.isContentEditable) { if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) { - // Checkbox is handled in click, we can't let input trigger on checkbox - that would mean we dispatched click events while recording. + // Checkbox is handled in click, no need to record a duplicate action for the input event. return; } - // Non-navigating actions are simply recorded by Playwright. - if (this._consumedDueWrongTarget(event)) - return; this._recordAction({ name: 'fill', - selector: this._activeModel!.selector, + selector: this._activeSelectorForEvent(event), signals: [], text: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value, }); @@ -459,7 +444,7 @@ class RecordActionTool implements RecorderTool { const selectElement = target as HTMLSelectElement; this._recordAction({ name: 'select', - selector: this._activeModel!.selector, + selector: this._activeSelectorForEvent(event), options: [...selectElement.selectedOptions].map(option => option.value), signals: [] }); @@ -471,46 +456,26 @@ class RecordActionTool implements RecorderTool { return; if (!this._shouldGenerateKeyPressFor(event)) return; - if (this._actionInProgress(event)) { - this._expectProgrammaticKeyUp = true; - return; - } - if (this._consumedDueWrongTarget(event)) - return; // Similarly to click, trigger checkbox on key event, not input. if (event.key === ' ') { const checkbox = asCheckbox(this._recorder.deepEventTarget(event)); if (checkbox && event.detail === 0) { - this._performAction({ + this._recordAction({ name: checkbox.checked ? 'uncheck' : 'check', - selector: this._activeModel!.selector, + selector: this._activeSelectorForEvent(event), signals: [], - }); + }, { autoExpect: true }); return; } } - this._performAction({ + this._recordAction({ name: 'press', - selector: this._activeModel!.selector, + selector: this._activeSelectorForEvent(event), signals: [], key: event.key, modifiers: modifiersForEvent(event), - }); - } - - onKeyUp(event: KeyboardEvent) { - if (this._dialog.isShowing()) - return; - if (!this._shouldGenerateKeyPressFor(event)) - return; - - // Only allow programmatic keyups, ignore user input. - if (!this._expectProgrammaticKeyUp) { - consumeEvent(event); - return; - } - this._expectProgrammaticKeyUp = false; + }, { autoExpect: true }); } onScroll(event: Event) { @@ -519,8 +484,12 @@ class RecordActionTool implements RecorderTool { this._resetHoveredModel(); } - private _showActionListDialog(model: HighlightModelWithSelector, event: MouseEvent) { + private _showActionListDialog(event: MouseEvent) { + // Right click is always intercepted and opens the actions dialog instead of being passed to the page. consumeEvent(event); + const model = this._hoveredModel ?? this._modelForElement(this._recorder.deepEventTarget(event)); + if (!model) + return; const actionPosition = positionForEvent(event); const actions: { title: string, cb: () => void }[] = [ { @@ -532,7 +501,7 @@ class RecordActionTool implements RecorderTool { signals: [], button: 'left', modifiers: 0, - clickCount: 0, + clickCount: 1, }), }, { @@ -544,7 +513,7 @@ class RecordActionTool implements RecorderTool { signals: [], button: 'right', modifiers: 0, - clickCount: 0, + clickCount: 1, }), }, { @@ -561,7 +530,7 @@ class RecordActionTool implements RecorderTool { }, { title: 'Hover', - cb: () => this._performAction({ + cb: () => this._recordAction({ name: 'hover', selector: model.selector, position: actionPosition, @@ -623,39 +592,26 @@ class RecordActionTool implements RecorderTool { return shouldIgnoreMouseEvent(this._recorder.deepEventTarget(event)); } - private _actionInProgress(event: Event): boolean { - // If Playwright is performing action for us, bail. - const isKeyEvent = event instanceof KeyboardEvent; - const isMouseOrPointerEvent = event instanceof MouseEvent || event instanceof PointerEvent; - for (const action of this._performingActions) { - if (isKeyEvent && action.name === 'press' && event.key === action.key) - return true; - if (isMouseOrPointerEvent && (action.name === 'click' || action.name === 'hover' || action.name === 'check' || action.name === 'uncheck')) - return true; - } - - // Consume event if action is not being executed. - consumeEvent(event); - return false; + private _consumeRightButtonEvent(event: MouseEvent) { + // Right click is intercepted to open the actions dialog, so the page should not see it. + if (event.button === 2 && !this._performingActions.size) + consumeEvent(event); } - private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean { - if (model) - return false; - consumeEvent(event); - return true; + private _selectorForElement(element: HTMLElement): string { + return this._recorder.injectedScript.generateSelector(element, { testIdAttributeName: this._recorder.state.testIdAttributeName }).selector; } - private _consumedDueWrongTarget(event: Event): boolean { - if (this._activeModel && this._activeModel.elements[0] === this._recorder.deepEventTarget(event)) - return false; - consumeEvent(event); - return true; + private _modelForElement(element: HTMLElement): HighlightModelWithSelector | null { + const { selector, elements } = this._recorder.injectedScript.generateSelector(element, { testIdAttributeName: this._recorder.state.testIdAttributeName }); + return selector ? { selector, elements, color: HighlightColors.action } : null; } - private _consumeWhenAboutToPerform(event: Event) { - if (!this._performingActions.size) - consumeEvent(event); + private _activeSelectorForEvent(event: Event): string { + const target = this._recorder.deepEventTarget(event); + if (this._activeModel && this._activeModel.elements[0] === target) + return this._activeModel.selector; + return this._selectorForElement(target); } private _reportPerformedActionForTests() { @@ -669,11 +625,11 @@ class RecordActionTool implements RecorderTool { })); } - private _recordAction(action: actions.Action) { - void this._recorder.recordAction(action).then(() => this._reportPerformedActionForTests()); + private _recordAction(action: actions.Action, options?: { autoExpect?: boolean }) { + void this._recorder.recordAction(action, options).then(() => this._reportPerformedActionForTests()); } - private _performAction(action: actions.PerformOnRecordAction) { + private _performAction(action: actions.PerformableAction) { this._recorder.updateHighlight(null, false); this._performingActions.add(action); @@ -1695,22 +1651,25 @@ export class Recorder { return documentElement ? this.injectedScript.utils.generateAriaTree(documentElement, { mode: 'autoexpect' }) : undefined; } - async performAction(action: actions.PerformOnRecordAction) { + private _computeAutoExpectPrecondition(action: actions.Action, autoExpect: boolean): string | undefined { const previousSnapshot = this._lastActionAutoexpectSnapshot; this._lastActionAutoexpectSnapshot = this._captureAutoExpectSnapshot(); - let preconditionSelector: string | undefined; - if (!isAssertAction(action) && this._lastActionAutoexpectSnapshot) { - const element = this.injectedScript.utils.findNewElement(previousSnapshot?.root, this._lastActionAutoexpectSnapshot?.root); - preconditionSelector = element ? this.injectedScript.generateSelector(element, { testIdAttributeName: this.state.testIdAttributeName }).selector : undefined; - if (preconditionSelector === action.selector) - preconditionSelector = undefined; - } - await this._delegate.performAction?.(action, preconditionSelector).catch(() => {}); + if (!autoExpect || isAssertAction(action) || !this._lastActionAutoexpectSnapshot) + return; + const element = this.injectedScript.utils.findNewElement(previousSnapshot?.root, this._lastActionAutoexpectSnapshot.root); + let preconditionSelector = element ? this.injectedScript.generateSelector(element, { testIdAttributeName: this.state.testIdAttributeName }).selector : undefined; + if ('selector' in action && preconditionSelector === action.selector) + preconditionSelector = undefined; + return preconditionSelector; } - async recordAction(action: actions.Action) { - this._lastActionAutoexpectSnapshot = this._captureAutoExpectSnapshot(); - await this._delegate.recordAction?.(action); + async performAction(action: actions.PerformableAction) { + await this._delegate.performAction?.(action).catch(() => {}); + } + + async recordAction(action: actions.Action, options?: { autoExpect?: boolean }) { + const preconditionSelector = this._computeAutoExpectPrecondition(action, !!options?.autoExpect); + await this._delegate.recordAction?.(action, preconditionSelector); } setOverlayState(state: { offsetX: number; }) { diff --git a/packages/isomorphic/codegen/actions.d.ts b/packages/isomorphic/codegen/actions.d.ts index f6dbc6b51a115..e75e5234bbc74 100644 --- a/packages/isomorphic/codegen/actions.d.ts +++ b/packages/isomorphic/codegen/actions.d.ts @@ -128,7 +128,7 @@ export type AssertSnapshotAction = ActionWithSelector & { export type Action = ClickAction | HoverAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction; export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction; -export type PerformOnRecordAction = ClickAction | HoverAction | CheckAction | UncheckAction | PressAction | SelectAction; +export type PerformableAction = ClickAction; // Signals. diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 5ef7ffc08f1e3..a49150e239b5b 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -224,14 +224,11 @@ export class Recorder extends EventEmitter implements Instrume return false; }); - // Input actions that potentially lead to navigation are intercepted on the page and are - // performed by the Playwright. await this._context.exposeBinding(progress, '__pw_recorderPerformAction', - (source: BindingSource, action: actions.PerformOnRecordAction, preconditionSelector?: string) => this._performAction(progress, source.frame, action, preconditionSelector)); + (source: BindingSource, action: actions.PerformableAction) => this._performAction(progress, source.frame, action)); - // Other non-essential actions are simply being recorded. await this._context.exposeBinding(progress, '__pw_recorderRecordAction', - (source: BindingSource, action: actions.Action) => this._recordAction(progress, source.frame, action)); + (source: BindingSource, action: actions.Action, preconditionSelector?: string) => this._recordAction(progress, source.frame, action, preconditionSelector)); await progress.race(this._context.extendInjectedScript(rawRecorderSource.source, { recorderMode: this._recorderMode, hideToolbar: !!this._params.hideToolbar })); }); @@ -556,22 +553,16 @@ export class Recorder extends EventEmitter implements Instrume return actionInContext; } - private async _performAction(progress: Progress, frame: Frame, action: actions.PerformOnRecordAction, preconditionSelector?: string) { + private async _performAction(progress: Progress, frame: Frame, action: actions.PerformableAction) { const framePath = await generateFrameSelector(progress, frame); - if (preconditionSelector) - this._signalProcessor.signal(frame, { name: 'expect', selector: buildFullSelector(framePath, preconditionSelector) }); - const actionInContext = this._appendContextToAction(frame, action, framePath); - this._signalProcessor.addAction(actionInContext); - try { - if (actionInContext.action.name !== 'openPage' && actionInContext.action.name !== 'closePage') - await performAction(progress, frame._page.mainFrame(), actionInContext); - } finally { - actionInContext.endTime = monotonicTime(); - } + const selector = buildFullSelector(framePath, action.selector); + await performAction(progress, frame._page.mainFrame(), { ...action, selector }); } - private async _recordAction(progress: Progress, frame: Frame, action: actions.Action) { + private async _recordAction(progress: Progress, frame: Frame, action: actions.Action, preconditionSelector?: string) { const framePath = await generateFrameSelector(progress, frame); + if (preconditionSelector) + this._signalProcessor.signal(frame, { name: 'expect', selector: buildFullSelector(framePath, preconditionSelector) }); const actionInContext = this._appendContextToAction(frame, action, framePath); this._signalProcessor.addAction(actionInContext); } diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index 6b2e92dee9e4f..0eba6bfff4f10 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -21,108 +21,9 @@ import type * as types from '../types'; import type * as actions from '@isomorphic/codegen/actions'; import type { Frame } from '../frames'; -export async function performAction(progress: Progress, mainFrame: Frame, actionInContext: actions.ActionInContext) { - const { action } = actionInContext; - - if (action.name === 'navigate') { - await mainFrame.goto(progress, action.url); - return; - } - - if (action.name === 'openPage') - throw Error('Not reached'); - - if (action.name === 'closePage') { - await mainFrame._page.close(progress); - return; - } - - const selector = action.selector; - - if (action.name === 'click') { - const options = toClickOptions(action); - await mainFrame.click(progress, selector, { ...options, strict: true }); - return; - } - - if (action.name === 'hover') { - await mainFrame.hover(progress, selector, { position: action.position, strict: true }); - return; - } - - if (action.name === 'press') { - const modifiers = toKeyboardModifiers(action.modifiers); - const shortcut = [...modifiers, action.key].join('+'); - await mainFrame.press(progress, selector, shortcut, { strict: true }); - return; - } - - if (action.name === 'fill') { - await mainFrame.fill(progress, selector, action.text, { strict: true }); - return; - } - - if (action.name === 'setInputFiles') { - await mainFrame.setInputFiles(progress, selector, { selector, payloads: [], strict: true }); - return; - } - - if (action.name === 'check') { - await mainFrame.check(progress, selector, { strict: true }); - return; - } - - if (action.name === 'uncheck') { - await mainFrame.uncheck(progress, selector, { strict: true }); - return; - } - - if (action.name === 'select') { - const values = action.options.map(value => ({ value })); - await mainFrame.selectOption(progress, selector, [], values, { strict: true }); - return; - } - - if (action.name === 'assertChecked') { - await mainFrame.expect(progress, selector, { - selector, - expression: 'to.be.checked', - expectedValue: { checked: action.checked }, - isNot: !action.checked, - }); - return; - } - - if (action.name === 'assertText') { - await mainFrame.expect(progress, selector, { - selector, - expression: 'to.have.text', - expectedText: [{ string: action.text, matchSubstring: true, normalizeWhiteSpace: true }], - isNot: false, - }); - return; - } - - if (action.name === 'assertValue') { - await mainFrame.expect(progress, selector, { - selector, - expression: 'to.have.value', - expectedValue: action.value, - isNot: false, - }); - return; - } - - if (action.name === 'assertVisible') { - await mainFrame.expect(progress, selector, { - selector, - expression: 'to.be.visible', - isNot: false, - }); - return; - } - - throw new Error('Internal error: unexpected action ' + (action as any).name); +export async function performAction(progress: Progress, mainFrame: Frame, action: actions.PerformableAction) { + const options = toClickOptions(action); + await mainFrame.click(progress, action.selector, { ...options, strict: true }); } export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { diff --git a/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts b/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts index fb510fe657f26..aedc4a4278b3c 100644 --- a/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts +++ b/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts @@ -26,21 +26,85 @@ export interface ProcessorDelegate { addSignal(signalInContext: actions.SignalInContext): void; } +// How long a single click is held back, waiting for a double click to arrive and merge with it. +const kClickBufferTimeout = 500; + +type BufferedSignal = { frame: Frame, signal: Signal, timestamp: number }; + export class RecorderSignalProcessor { private _delegate: ProcessorDelegate; private _lastAction: actions.ActionInContext | null = null; + private _bufferedClick: { actionInContext: actions.ActionInContext, signals: BufferedSignal[], timeout: NodeJS.Timeout } | undefined; constructor(actionSink: ProcessorDelegate) { this._delegate = actionSink; } addAction(actionInContext: actions.ActionInContext) { - this._lastAction = actionInContext; - this._delegate.addAction(actionInContext); + if (this._bufferedClick) { + if (this._isDoubleClick(actionInContext, this._bufferedClick.actionInContext)) { + // A double click - merge it into the buffered single click and emit the result. + actionInContext.startTime = this._bufferedClick.actionInContext.startTime; + this._flushBufferedClick(actionInContext); + return; + } + // A different action - emit the buffered click before proceeding. + this._flushBufferedClick(); + } + + if (this._isBufferableClick(actionInContext)) { + this._bufferedClick = { + actionInContext, + signals: [], + timeout: setTimeout(() => this._flushBufferedClick(), kClickBufferTimeout), + }; + return; + } + + this._emitAction(actionInContext); } signal(frame: Frame, signal: Signal) { const timestamp = monotonicTime(); + if (this._bufferedClick) { + this._bufferedClick.signals.push({ frame, signal, timestamp }); + return; + } + this._processSignal(frame, signal, timestamp); + } + + private _isBufferableClick(actionInContext: actions.ActionInContext): boolean { + const action = actionInContext.action; + return action.name === 'click' && action.button === 'left' && action.clickCount === 1; + } + + private _isDoubleClick(actionInContext: actions.ActionInContext, bufferedClick: actions.ActionInContext): boolean { + const action = actionInContext.action; + const buffered = bufferedClick.action; + return action.name === 'click' && buffered.name === 'click' + && actionInContext.pageGuid === bufferedClick.pageGuid + && action.selector === buffered.selector + && action.clickCount > buffered.clickCount; + } + + private _emitAction(actionInContext: actions.ActionInContext) { + this._lastAction = actionInContext; + this._delegate.addAction(actionInContext); + } + + private _flushBufferedClick(replacement?: actions.ActionInContext) { + const buffered = this._bufferedClick; + if (!buffered) + return; + clearTimeout(buffered.timeout); + this._bufferedClick = undefined; + this._emitAction(replacement ?? buffered.actionInContext); + // Replay the signals with their original timestamps, so that they attach to the emitted action. + for (const { frame, signal, timestamp } of buffered.signals) + this._processSignal(frame, signal, timestamp); + } + + private _processSignal(frame: Frame, signal: Signal, timestamp: number) { if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { const lastAction = this._lastAction; const signalThreshold = isUnderTest() ? 500 : 5000; @@ -54,7 +118,7 @@ export class RecorderSignalProcessor { generateGoto = true; if (generateGoto) { - this.addAction({ + this._emitAction({ pageGuid: frame._page.guid, action: { name: 'navigate', diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index fd66b37da8c9e..38abf76dde1b7 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -62,10 +62,6 @@ function isSameSelector(action: actions.ActionInContext, lastAction: actions.Act return 'selector' in action.action && 'selector' in lastAction.action && action.action.selector === lastAction.action.selector; } -function isShortlyAfter(action: actions.ActionInContext, lastAction: actions.ActionInContext): boolean { - return action.startTime - lastAction.startTime < 500; -} - export function shouldMergeAction(action: actions.ActionInContext, lastAction: actions.ActionInContext | undefined): boolean { if (!lastAction) return false; @@ -74,8 +70,6 @@ export function shouldMergeAction(action: actions.ActionInContext, lastAction: a return isSameAction(action, lastAction) && isSameSelector(action, lastAction); case 'navigate': return isSameAction(action, lastAction); - case 'click': - return isSameAction(action, lastAction) && isSameSelector(action, lastAction) && isShortlyAfter(action, lastAction) && action.action.clickCount > (lastAction.action as actions.ClickAction).clickCount; } return false; } diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 0bdb25cc24658..784b5e00253c1 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -225,7 +225,7 @@ test('should record expect signal', async ({ backend, connectedBrowser }) => { `); await page.getByRole('button', { name: 'Show' }).click(); - // A click stalls for 200ms to detect a double click, and the next click cancels a pending one. + // A click is buffered for a while to detect a double click, so wait for it to be recorded. await expect.poll(() => events[events.length - 1]?.actions.length).toBe(2); await page.getByRole('button', { name: 'Other' }).click(); diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index d793079e48ff9..d228c7b7d7552 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -556,6 +556,32 @@ await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`); expect(messages[1].text()).toBe('up:ArrowDown'); }); + test('should not record a click on Enter press', async ({ openRecorder }) => { + const { page, recorder } = await openRecorder(); + + await recorder.setContentAndWait(``); + + const locator = await recorder.focusElement('button'); + expect(locator).toBe(`getByRole('button', { name: 'Submit' })`); + + const [message] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error'), + recorder.waitForOutput('JavaScript', `press('Enter')`), + page.keyboard.press('Enter'), + ]); + expect(message.text()).toBe('clicked'); + + // Wait for the next action to be recorded, to make sure the keyboard-activated + // click event that follows the Enter press did not produce a click action. + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', `press('Tab')`), + page.keyboard.press('Tab'), + ]); + expect(sources.get('JavaScript')!.text).toContain(` + await page.getByRole('button', { name: 'Submit' }).press('Enter');`); + expect(sources.get('JavaScript')!.text).not.toContain(`click()`); + }); + test('should check', async ({ openRecorder }) => { const { page, recorder } = await openRecorder(); diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index e6851934698e0..4dbf8dbe990dc 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -666,7 +666,7 @@ await page.GetByRole(AriaRole.Textbox, new() { Name = "Country" }).ClickAsync(); await page.GetByRole(AriaRole.Textbox, new() { Name = \"Coun\\\"try\" }).ClickAsync();`); }); - test('should consume pointer events', async ({ openRecorder }) => { + test('should pass through pointer events', async ({ openRecorder }) => { const { page, recorder } = await openRecorder(); await recorder.setContentAndWait(` @@ -690,8 +690,6 @@ await page.GetByRole(AriaRole.Textbox, new() { Name = \"Coun\\\"try\" }).ClickAs expect(message.text()).toBe('clicked'); expect(await page.evaluate('log')).toEqual([ 'pointermove', 'mousemove', - 'pointermove', - 'mousemove', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', @@ -744,6 +742,52 @@ await page.GetByRole(AriaRole.Textbox, new() { Name = \"Coun\\\"try\" }).ClickAs expect(await page.evaluate('log')).toEqual((isWindows && browserName === 'chromium') ? ['button: auxclick', 'button: contextmenu'] : ['button: contextmenu']); }); + test('should generate click action from dialog', async ({ openRecorder }) => { + const { page, recorder } = await openRecorder(); + + await recorder.setContentAndWait(``); + await recorder.hoverOverElement('button'); + + const action = async () => { + await recorder.trustedClick({ button: 'right' }); + await recorder.page.getByRole('listitem', { name: 'Click', exact: true }).click(); + }; + + // The dialog kicks off a click; the page reacts and the click is recorded naturally. + const [message, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error'), + recorder.waitForOutput('JavaScript', 'click'), + action(), + ]); + expect(message.text()).toBe('clicked'); + expect(sources.get('JavaScript')!.text).toContain(` + await page.getByRole('button', { name: 'Submit' }).click();`); + }); + + test('should generate double click action from dialog', async ({ openRecorder }) => { + const { page, recorder } = await openRecorder(); + + await recorder.setContentAndWait(``); + await recorder.hoverOverElement('button'); + + const action = async () => { + await recorder.trustedClick({ button: 'right' }); + await recorder.page.getByRole('listitem', { name: 'Double click' }).click(); + }; + + // The dialog kicks off a double click; it is recorded as a single dblclick action. + const [message, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclicked'), + recorder.waitForOutput('JavaScript', 'dblclick'), + action(), + ]); + expect(message.text()).toBe('dblclicked'); + const text = sources.get('JavaScript')!.text; + expect(text).toContain(` + await page.getByRole('button', { name: 'Submit' }).dblclick();`); + expect(text).not.toContain(`.click();`); + }); + test('should generate hover action', async ({ openRecorder }) => { const { recorder } = await openRecorder(); @@ -1082,9 +1126,22 @@ await page.GetByTestId("testid").HoverAsync();`); `); - await page.getByRole('button', { name: 'Go Fullscreen' }).click(); - await expect(page.getByRole('button', { name: 'Close Fullscreen' })).toBeVisible(); + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'Go Fullscreen'), + page.getByRole('button', { name: 'Go Fullscreen' }).click(), + ]); + expect(sources.get('JavaScript')!.text).toContain(`getByRole('button', { name: 'Go Fullscreen' }).click()`); + await page.waitForFunction(() => !!document.fullscreenElement); + + // Actions inside the fullscreen element are recorded. + const [sources2] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'Close Fullscreen'), + page.getByRole('button', { name: 'Close Fullscreen' }).click(), + ]); + expect(sources2.get('JavaScript')!.text).toContain(`getByRole('button', { name: 'Close Fullscreen' }).click()`); + await page.waitForFunction(() => !document.fullscreenElement); + // After exiting fullscreen, the toolbar is clickable again. await page.getByTitle('Assert text').click(); }); }); diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index 568c09df57663..a86ff53fb45bc 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -52,8 +52,7 @@ test('should click', async ({ context, browserName, platform, channel }) => { await page.setContent(``); await page.getByRole('button', { name: 'Submit' }).click(); - const clickActions = log.action('click'); - expect(clickActions).toEqual([ + await expect.poll(() => log.action('click')).toEqual([ expect.objectContaining({ action: expect.objectContaining({ name: 'click', @@ -66,7 +65,7 @@ test('should click', async ({ context, browserName, platform, channel }) => { }) ]); - expect(normalizeCode(clickActions[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).click();`); + expect(normalizeCode(log.action('click')[0].code)).toEqual(`await page.getByRole('button', { name: 'Submit' }).click();`); }); test('should double click', async ({ context, browserName, platform, channel }) => { @@ -146,9 +145,11 @@ test('should disable recorder', async ({ context }) => { await page.setContent(``); await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click(); - expect(log.action('click')).toHaveLength(2); + await expect.poll(() => log.action('click').length).toBe(2); await (context as any)._disableRecorder(); await page.getByRole('button', { name: 'Submit' }).click(); + // Give it some time to produce more actions - there should be none. + await page.waitForTimeout(2000); expect(log.action('click')).toHaveLength(2); }); From dba907bdbc6fd0cb78f8fabcc9611fe93b4f5239 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 23 Jul 2026 15:29:04 +0100 Subject: [PATCH 4/7] feat: support pierce-frames selector (#41778) --- packages/injected/src/injectedScript.ts | 2 + packages/isomorphic/selectorParser.ts | 28 ++- .../src/server/frameSelectors.ts | 218 ++++++++++++++---- packages/playwright-core/src/server/frames.ts | 12 +- .../playwright-core/src/server/javascript.ts | 2 +- tests/page/selectors-frame.spec.ts | 86 ++++++- 6 files changed, 285 insertions(+), 63 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 4c61203318e79..415f9cd58f786 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -539,6 +539,8 @@ export class InjectedScript { queryAll(root: SelectorRoot, body: any) { if (body === 'enter-frame') return []; + if (body === 'pierce-frames') + return []; if (body === 'return-empty') return []; if (body === 'component') { diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index 9da6c48cd0a8c..0e084733c55cf 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -91,19 +91,33 @@ export function parseSelector(selector: string): ParsedSelector { }; } -export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { +// Splits a selector into per-frame chunks separated by "enter-frame" boundaries. When the selector +// starts with the "pierce-frames" token, `pierce` is set globally and no "enter-frame" boundaries +// are allowed (piercing already searches every descendant frame), so `chunks` holds a single chunk. +export function splitSelectorByFrame(selectorText: string): { pierce: boolean, chunks: ParsedSelector[] } { const selector = parseSelector(selectorText); - const result: ParsedSelector[] = []; + const chunks: ParsedSelector[] = []; let chunk: ParsedSelector = { parts: [], }; + let pierce = false; let chunkStartIndex = 0; for (let i = 0; i < selector.parts.length; ++i) { const part = selector.parts[i]; + if (part.name === 'internal:control' && part.body === 'pierce-frames') { + // Piercing is a whole-page operation, so it only makes sense as the very first token. + if (i !== 0) + throw new InvalidSelectorError(`"pierce-frames" is only allowed as the first selector token, while parsing selector ${selectorText}`); + pierce = true; + chunkStartIndex = i + 1; + continue; + } if (part.name === 'internal:control' && part.body === 'enter-frame') { + if (pierce) + throw new InvalidSelectorError(`Entering frames is not allowed while piercing frames, while parsing selector ${selectorText}`); if (!chunk.parts.length) throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first'); - result.push(chunk); + chunks.push(chunk); chunk = { parts: [] }; chunkStartIndex = i + 1; continue; @@ -114,10 +128,12 @@ export function splitSelectorByFrame(selectorText: string): ParsedSelector[] { } if (!chunk.parts.length) throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`); - result.push(chunk); - if (typeof selector.capture === 'number' && typeof result[result.length - 1].capture !== 'number') + chunks.push(chunk); + if (typeof selector.capture === 'number' && typeof chunks[chunks.length - 1].capture !== 'number') throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`); - return result; + if (typeof selector.capture === 'number' && pierce) + throw new InvalidSelectorError(`Can not *-capture inside a frame-piercing selector, while parsing selector ${selectorText}`); + return { pierce, chunks }; } function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) { diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index e6b071d9713ed..039e318fdc21b 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -17,10 +17,12 @@ import { InvalidSelectorError, splitSelectorByFrame, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser'; import { asLocator } from '@isomorphic/locatorGenerators'; +import { NonRecoverableDOMError } from './dom'; + import type { ElementHandle, FrameExecutionContext } from './dom'; import type { Frame } from './frames'; import type { InjectedScript } from '@injected/injectedScript'; -import type { JSHandle, SmartHandle } from './javascript'; +import type { JSHandle, SmartHandle, Unboxed } from './javascript'; import type * as types from './types'; import type { ParsedSelector } from '@isomorphic/selectorParser'; @@ -31,13 +33,13 @@ export type SelectorInfo = { strict: boolean, }; -export type SelectorInFrame = { +type SelectorInFrame = { frame: Frame; info: SelectorInfo; scope?: ElementHandle; }; -type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo }, arg: Arg) => R | Promise; +type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo }, arg: Unboxed) => R | Promise; export class FrameSelectors { readonly frame: Frame; @@ -117,19 +119,36 @@ export class FrameSelectors { return jumptToFrame; } - private async _resolveFrameForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { - let frame: Frame = this.frame; - const frameChunks = splitSelectorByFrame(selector); - - for (const chunk of frameChunks) { + private async _resolveFramesForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { + const { pierce, chunks } = splitSelectorByFrame(selector); + for (const chunk of chunks) { visitAllSelectorParts(chunk, (part, nested) => { if (nested && part.name === 'internal:control' && part.body === 'enter-frame') { const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`); } + if (nested && pierce) { + const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); + throw new InvalidSelectorError(`Composite locators are not supported with piercing frames, while querying "${locator}"`); + } }); } + if (pierce) { + const parsed = chunks[0]; // Only one chunk is allowed with pierce. + if (parsed.parts.some((part, index) => part.name === 'nth' && index !== parsed.parts.length - 1)) { + const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); + throw new InvalidSelectorError(`nth can only be the last locator when piercing frames, while querying "${locator}"`); + } + return await this._resolveFramePiercingSelector(parsed, options, scope); + } + + const result = await this._resolveChainedSelector(selector, options, chunks, scope); + return result ? [result] : []; + } + + private async _resolveChainedSelector(selector: string, options: types.StrictOptions, frameChunks: ParsedSelector[], scope: ElementHandle | undefined): Promise { + let frame: Frame = this.frame; for (let i = 0; i < frameChunks.length - 1; ++i) { const info = this._parseSelector(frameChunks[i], options); frame = this._jumpToAriaRefFrameIfNeeded(selector, info, frame); @@ -158,13 +177,143 @@ export class FrameSelectors { return { frame, info: lastChunk, scope }; } - private async _resolveInjectedForSelector(selector: string, options: types.StrictOptions & { mainWorld?: boolean }, scope?: ElementHandle): Promise<{ frame: Frame, info: SelectorInfo, injected: JSHandle, scope?: ElementHandle } | null> { - const resolved = await this._resolveFrameForSelector(selector, options, scope); - if (!resolved) - return null; - const context = await resolved.frame.context(options.mainWorld ? 'main' : resolved.info.world); - const injected = await context.injectedScript(); - return { frame: resolved.frame, info: resolved.info, injected, scope: resolved.scope }; + private async _resolveFramePiercingSelector(parsed: ParsedSelector, options: types.StrictOptions, scope: ElementHandle | undefined) { + const candidates = new Map>(); + const infos = parsed.parts.map(part => this._parseSelector({ parts: [part] }, options)); + for (const frame of this.frame._page.frameManager.frames()) + await this._pierceFramesRecursivelyIfNotSeen(frame, infos, scope, 0, candidates); + const result: SelectorInFrame[] = []; + for (const [frame, matches] of candidates) { + for (const match of matches) { + const suffix = infos.slice(match); + const partialInfo: SelectorInfo = { + parsed: { parts: suffix.map(info => info.parsed.parts[0]) }, + world: suffix.some(info => info.world === 'main') ? 'main' : 'utility', + strict: !!options.strict, + }; + result.push({ frame, info: partialInfo }); + } + } + return result; + } + + private async _pierceFramesRecursivelyIfNotSeen(frame: Frame, infos: SelectorInfo[], scope: ElementHandle | undefined, startIndex: number, result: Map>) { + let set = result.get(frame); + if (!set) { + set = new Set(); + result.set(frame, set); + } + if (!set.has(startIndex)) { + set.add(startIndex); + await this._pierceFramesRecursively(frame, infos, undefined, startIndex, result); + } + } + + private async _pierceFramesRecursively(frame: Frame, infos: SelectorInfo[], scope: ElementHandle | undefined, startIndex: number, result: Map>) { + const doWork = async (context: FrameExecutionContext) => { + const injected = await context.injectedScript(); + const frameCandidatesHandle = await injected.evaluateHandle((injected, { infos, scope, startIndex }) => { + const frameElements = injected.querySelectorAll(injected.parseSelector('css=frame,iframe'), scope || document); + const result = frameElements.map(frameElement => ({ frameElement, matches: [] as number[] })); + + let roots = [scope || document]; + for (let index = startIndex; index < infos.length; index++) { + const next = new Set(); + for (const root of roots) { + const all = injected.querySelectorAll(infos[index].parsed, root); + for (const element of all) + next.add(element); + } + roots = [...next]; + if (index + 1 < infos.length && !['nth', 'visible'].includes(infos[index + 1].parsed.parts[0].name)) { + for (const { frameElement, matches } of result) { + if (roots.some(root => injected.utils.isInsideScope(root, frameElement))) + matches.push(index); + } + } + } + return result; + }, { infos, scope, startIndex }); + + const count = await frameCandidatesHandle.evaluate(x => x.length).catch(() => 0); + for (let i = 0; i < count; ++i) { + try { + const frameElement = await frameCandidatesHandle.evaluateHandle((list, i) => list[i].frameElement, i) as ElementHandle; + const childFrame = await frame._page.delegate.getContentFrame(frameElement).catch(() => null); + if (childFrame) { + const matches = await frameCandidatesHandle.evaluate((list, i) => list[i].matches, i) as number[]; + for (const match of matches) + await this._pierceFramesRecursivelyIfNotSeen(childFrame, infos, undefined, match + 1, result); + } + } catch { + // Ignore errors for this frame candidate. + } + } + frameCandidatesHandle.dispose(); + }; + + const noStall = frame !== this.frame; + const world = infos.some(info => info.world === 'main') ? 'main' : 'utility'; + const context = noStall ? frame.existingContext(world) : await frame.context(world); + if (!context) + return; + + if (noStall) + await frame.raceAgainstEvaluationStallingEvents(() => doWork(context)).catch(() => {}); + else + await doWork(context); + } + + private async _callOnSelectorInternal( + selector: string, + options: types.StrictOptions & { mainWorld?: boolean, callWithoutMatches?: boolean, scope?: ElementHandle, markTargets?: 'all' | 'first' | 'none' }, + pageFunction: MatchedElementsCallback, + arg: Arg, + returnByValue: boolean, + ): Promise<{ frame: Frame, info: SelectorInfo, result: R | SmartHandle } | null> { + const resolved = await this._resolveFramesForSelector(selector, options, options.scope); + let aggregatedResult: { frame: Frame, info: SelectorInfo, result: R | SmartHandle } | null = null; + const noStall = resolved.length > 1; + for (const { frame, info, scope } of resolved) { + const world = options.mainWorld ? 'main' : info.world; + const context = noStall ? frame.existingContext(world) : await frame.context(world); + if (!context) + continue; + const getResult = async () => { + const injected = await context.injectedScript(); + const method = returnByValue ? 'evaluate' : 'evaluateHandle'; + const evalResult = await injected[method]((injected, params) => { + const elements = injected.querySelectorAll(params.info.parsed, params.scope || document); + if (params.markTargets === 'all') + injected.markTargetElements(new Set(elements)); + else if (params.markTargets === 'first' && elements.length) + injected.markTargetElements(new Set([elements[0]])); + else if (params.markTargets === 'first') + injected.markTargetElements(new Set()); + injected.checkDeprecatedSelectorUsage(params.info.parsed, elements); + if (params.info.strict && elements.length > 1) + throw injected.strictModeViolationError(params.info.parsed, elements); + if (!elements.length && !params.callWithoutMatches) + return '--playwright--no--result--value--'; + const func = injected.eval('(' + params.functionText + ')') as MatchedElementsCallback; + return func({ injected, elements, info: params.info }, params.arg); + }, { info, scope, functionText: String(pageFunction), arg, callWithoutMatches: options.callWithoutMatches, markTargets: options.markTargets, returnByValue }); + if (returnByValue && evalResult === '--playwright--no--result--value--') + return; + if (!returnByValue && (evalResult as JSHandle)._value === '--playwright--no--result--value--') { + (evalResult as JSHandle).dispose(); + return; + } + return { result: evalResult as R | SmartHandle }; + }; + const maybeResult = noStall ? await frame.raceAgainstEvaluationStallingEvents(getResult).catch(() => undefined) : await getResult(); + if (!maybeResult) + continue; + if (aggregatedResult) + throw new NonRecoverableDOMError(`Pierce-frame mode matched elements from multiple frames.`); + aggregatedResult = { frame, info, result: maybeResult.result }; + } + return aggregatedResult; } async callOnSelector( @@ -173,14 +322,8 @@ export class FrameSelectors { pageFunction: MatchedElementsCallback, arg: Arg, ): Promise<{ frame: Frame, info: SelectorInfo, result: R } | null> { - const resolved = await this._resolveInjectedForSelector(selector, options, options.scope); - if (!resolved) - return null; - const result = await resolved.injected.evaluate(callMatchedElements, { info: resolved.info, scope: resolved.scope, functionText: String(pageFunction), arg, callWithoutMatches: !!options.callWithoutMatches, markTargets: options.markTargets }) as R; - // callMatchedElements returns undefined when there were no matches and it skipped the page function. - if (!options.callWithoutMatches && result === undefined) - return null; - return { frame: resolved.frame, info: resolved.info, result }; + const result = await this._callOnSelectorInternal(selector, options, pageFunction, arg, true /* returnByValue */); + return result as { frame: Frame, info: SelectorInfo, result: R } | null; } async callOnSelectorHandle( @@ -189,36 +332,11 @@ export class FrameSelectors { pageFunction: MatchedElementsCallback, arg: Arg, ): Promise<{ result: SmartHandle } | null> { - const resolved = await this._resolveInjectedForSelector(selector, options, options.scope); - if (!resolved) - return null; - const result = await resolved.injected.evaluateHandle(callMatchedElements, { info: resolved.info, scope: resolved.scope, functionText: String(pageFunction), arg, callWithoutMatches: false, markTargets: options.markTargets }) as SmartHandle; - // A skipped page function returns undefined, which has no object id (unlike a matched element/object). - if (!result._objectId) { - result.dispose(); - return null; - } - return { result }; + const result = await this._callOnSelectorInternal(selector, { ...options, callWithoutMatches: false }, pageFunction, arg, false /* returnByValue */); + return result as { frame: Frame, info: SelectorInfo, result: SmartHandle } | null; } } -function callMatchedElements(injected: InjectedScript, { info, scope, functionText, arg, callWithoutMatches, markTargets }: { info: SelectorInfo, scope: Node | undefined, functionText: string, arg: any, callWithoutMatches: boolean, markTargets?: 'all' | 'first' | 'none' }): any { - const elements = injected.querySelectorAll(info.parsed, scope || document); - if (markTargets === 'all') - injected.markTargetElements(new Set(elements)); - else if (markTargets === 'first' && elements.length) - injected.markTargetElements(new Set([elements[0]])); - else if (markTargets === 'first') - injected.markTargetElements(new Set()); - injected.checkDeprecatedSelectorUsage(info.parsed, elements); - if (info.strict && elements.length > 1) - throw injected.strictModeViolationError(info.parsed, elements); - if (!elements.length && !callWithoutMatches) - return undefined; - const pageFunction = injected.eval('(' + functionText + ')'); - return pageFunction({ injected, elements, info }, arg); -} - async function adoptIfNeeded(handle: ElementHandle, context: FrameExecutionContext): Promise> { if (handle._context === context) return handle; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 170843cfe6839..ff517998054eb 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -84,7 +84,7 @@ export type NavigationEvent = { isPublic?: boolean; }; -type ElementCallback = (injected: InjectedScript, element: Element, data: T) => R; +type ElementCallback = (injected: InjectedScript, element: Element, data: js.Unboxed) => R; export class NavigationAbortedError extends Error { readonly documentId?: string; @@ -619,7 +619,7 @@ export class Frame extends SdkObject { nonStallingRawEvaluateInExistingMainContext(expression: string): Promise { return this.raceAgainstEvaluationStallingEvents(() => { - const context = this._existingMainContext(); + const context = this.existingContext('main'); if (!context) throw new Error('Frame does not yet have a main execution context'); return context.rawEvaluateJSON(expression); @@ -798,8 +798,10 @@ export class Frame extends SdkObject { return this.context('main'); } - private _existingMainContext(): dom.FrameExecutionContext | null { - return this._contextData.get('main')?.context || null; + existingContext(world: types.World): dom.FrameExecutionContext | null { + if (this._page.delegate.noUtilityWorld?.()) + world = 'main'; + return this._contextData.get(world)?.context || null; } utilityContext(): Promise { @@ -1781,7 +1783,7 @@ export class Frame extends SdkObject { const resolved = await progress.race(this.selectors.callOnSelector(selector, { ...options, scope, markTargets: 'first' }, ({ injected, elements }, { callbackText, taskData }) => { const callback = injected.eval(callbackText) as ElementCallback; const element = elements[0]; - const value = callback(injected, element, taskData); + const value = callback(injected, element, taskData as js.Unboxed); if (!value) return { success: false }; const log = ` locator resolved to ${injected.previewNode(element)}`; diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index 17aaf1683e229..0f549a0273542 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -31,7 +31,7 @@ interface TaggedAsElementHandle { __elementhandle: T; } type NoHandles = Arg extends TaggedAsJSHandle ? never : (Arg extends object ? { [Key in keyof Arg]: NoHandles } : Arg); -type Unboxed = +export type Unboxed = Arg extends TaggedAsElementHandle ? T : Arg extends TaggedAsJSHandle ? T : Arg extends NoHandles ? Arg : diff --git a/tests/page/selectors-frame.spec.ts b/tests/page/selectors-frame.spec.ts index 3ccfb6acd14af..8a305157bbc91 100644 --- a/tests/page/selectors-frame.spec.ts +++ b/tests/page/selectors-frame.spec.ts @@ -40,7 +40,7 @@ async function routeIframe(page: Page) { }); await page.route('**/iframe-2.html', route => { route.fulfill({ - body: '', + body: '', contentType: 'text/html' }).catch(() => {}); }); @@ -310,3 +310,87 @@ it('should non work for non-frame', async ({ page, server }) => { expect(error.message).toContain('
'); expect(error.message).toContain('', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/a.html', route => { + route.fulfill({ body: '
one
', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/b.html', route => { + route.fulfill({ body: 'twothree', contentType: 'text/html' }).catch(() => {}); + }); + await page.goto(server.EMPTY_PAGE); + + const texts = await page.$$eval('internal:control=pierce-frames >> span', els => els.map(e => e.textContent)); + expect(texts).toEqual(['two', 'three']); +}); + +it('should throw when piercing frames matches multiple frames', async ({ page, server }) => { + await page.route('**/empty.html', route => { + route.fulfill({ body: '', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/a.html', route => { + route.fulfill({ body: '
one
', contentType: 'text/html' }).catch(() => {}); + }); + await page.route('**/b.html', route => { + route.fulfill({ body: '
two
', contentType: 'text/html' }).catch(() => {}); + }); + await page.goto(server.EMPTY_PAGE); + + // Make sure both child frames have their
before piercing, otherwise resolution + // may transiently collapse to a single frame. + await expect.poll(() => page.frames().length).toBe(3); + for (const frame of page.frames()) { + if (frame !== page.mainFrame()) + await frame.waitForSelector('div'); + } + + const error = await page.locator('internal:control=pierce-frames >> div').innerHTML().catch(e => e); + expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); +}); + +it('should not allow pierce-frames in the middle of a selector', async ({ page, server }) => { + await routeIframe(page); + await page.goto(server.EMPTY_PAGE); + const error = await page.locator('iframe >> internal:control=pierce-frames >> div').waitFor().catch(e => e); + expect(error.message).toContain('"pierce-frames" is only allowed as the first selector token'); +}); + +it('should not allow entering frames while piercing', async ({ page, server }) => { + await routeIframe(page); + await page.goto(server.EMPTY_PAGE); + const error = await page.locator('internal:control=pierce-frames >> iframe >> internal:control=enter-frame >> div').waitFor().catch(e => e); + expect(error.message).toContain('Entering frames is not allowed while piercing frames'); +}); From 41813407ea9f0a86c3a8477dcdd501626eb878f9 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 23 Jul 2026 17:18:34 +0200 Subject: [PATCH 5/7] perf(test): avoid redundant stack capture (#41961) Copilot-Session: c00ba4a1-a424-46ae-967e-2ddf02f91511 --- packages/playwright/src/matchers/expect.ts | 1 + packages/playwright/src/worker/testInfo.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index a027b71bc0949..f50c37fb74919 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -343,6 +343,7 @@ function callMatcherAsStep(matcherName: string, info: ExpectMetaInfo, actual: un apiName, title: longTitle, shortTitle, + location: stackFrames[0], params: args[0] ? { expected: args[0] } : undefined, }; const step = testInfo?._addStep(stepData); diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 7e20abe21e0ba..3fce56e1b7e3e 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -295,14 +295,13 @@ export class TestInfoImpl implements TestInfo { parentStep = this._parentStep(); } - const filteredStack = filteredStackTrace(captureRawStack()); let boxedStack = parentStep?.boxedStack; let location = data.location; if (!boxedStack && data.box) { - boxedStack = filteredStack.slice(1); - location = location || boxedStack[0]; + boxedStack = filteredStackTrace(captureRawStack()).slice(1); + location ??= boxedStack[0]; } - location = location || filteredStack[0]; + location ??= filteredStackTrace(captureRawStack())[0]; const step: TestStepInternal = { ...data, From 610977b294d916c8936953d28f9cfcb8c37d22a8 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 23 Jul 2026 09:31:33 -0600 Subject: [PATCH 6/7] fix(mcp): surface the missing executable path when a browser isn't installed (#41941) when a browser binary is missing, `throwIfExecutableMissing` caught playwright-core's `Executable doesn't exist at ` and rethrew a generic `Browser "" is not installed`, dropping the path that path holds the version-specific directory (e.g. `firefox-1534`), which is what tells a genuinely missing install apart from a version mismatch browsers from a standalone `playwright install` land in a differently-versioned directory than the bundled `playwright-core` expects keep the path in the rethrown message so the mismatch is diagnosable this is a further improvement for referenced in --- .../src/tools/mcp/browserFactory.ts | 11 ++++--- tests/mcp/launch.spec.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 376428c51b185..9f593c31c03f8 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -250,12 +250,15 @@ export function isProfileLocked(userDataDir: string): boolean { function throwIfExecutableMissing(error: Error, config: FullConfig): void { // The "Executable doesn't exist" prefix is shared by all managed binaries // (browser, ffmpeg, winldd). Disambiguate by the path so the user is told - // which dependency to install. + // which dependency to install, and surface the executable path itself so a + // version mismatch (an installed build vs. the expected build) is + // diagnosable rather than looking like a missing install. if (!error.message.includes(`Executable doesn't exist`)) return; const target = error.message.includes('ffmpeg') ? 'ffmpeg' : (config.browser.launchOptions?.channel ?? config.browser.browserName); const label = target === 'ffmpeg' ? 'FFmpeg' : `Browser "${target}"`; - if (config.skillMode) - throw new Error(`${label} is not installed. Run \`playwright-cli install-browser ${target}\` to install`); - throw new Error(`${label} is not installed. Run \`npx @playwright/mcp install-browser ${target}\` to install`); + const command = config.skillMode ? `playwright-cli install-browser ${target}` : `npx @playwright/mcp install-browser ${target}`; + const match = error.message.match(/Executable doesn't exist at ([^\r\n]+)/); + const location = match ? `; expected executable at ${match[1].trim()}` : ''; + throw new Error(`${label} is not installed${location}. Run \`${command}\` to install`); } diff --git a/tests/mcp/launch.spec.ts b/tests/mcp/launch.spec.ts index 031581f6fab53..c501934648218 100644 --- a/tests/mcp/launch.spec.ts +++ b/tests/mcp/launch.spec.ts @@ -61,6 +61,36 @@ test('executable path', async ({ startClient, server }) => { }); }); +test('surfaces the missing browser executable path so a version mismatch is diagnosable', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41871' }, +}, async ({ startClient, server, mcpBrowser }, testInfo) => { + test.skip(mcpBrowser === 'chrome' || mcpBrowser === 'msedge', 'Channel browsers use system-installed binaries, which are unaffected by PLAYWRIGHT_BROWSERS_PATH'); + + const emptyBrowsersPath = testInfo.outputPath('empty-browsers'); + await fs.promises.mkdir(emptyBrowsersPath, { recursive: true }); + + const { client } = await startClient({ + env: { PLAYWRIGHT_BROWSERS_PATH: emptyBrowsersPath }, + }); + + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + // The surfaced path must include the version-specific browser directory + // (e.g. chromium-1234) — that's the detail that reveals a version mismatch, + // as opposed to a generic "not installed". + const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + expect.soft(response).toHaveResponse({ + isError: true, + error: expect.stringContaining('is not installed'), + }); + expect.soft(response).toHaveResponse({ + isError: true, + error: expect.stringMatching(new RegExp(escapeRegExp(emptyBrowsersPath) + String.raw`[\\/][\w.]+-\d+[\\/]`)), + }); +}); + test('persistent context', async ({ startClient, server }, testInfo) => { server.setContent('/', ` From 914284d0b016cbb9d89e0665667fa56be3d5d7e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:15:11 -0600 Subject: [PATCH 7/7] chore(deps-dev): bump fast-uri from 3.1.3 to 3.1.4 (#41956) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c2121a1261b9..7990ae88323c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5625,9 +5625,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ {