From 2d8b728b70322c12d87f9eafbf57bf46c07aef50 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 2 Mar 2026 18:32:16 -0800 Subject: [PATCH 1/3] feat: add Page.cancelPickLocator() API --- docs/src/api/class-page.md | 6 ++++++ packages/playwright-client/types/types.d.ts | 6 ++++++ packages/playwright-core/src/client/page.ts | 4 ++++ packages/playwright-core/src/protocol/validator.ts | 2 ++ .../src/server/dispatchers/pageDispatcher.ts | 8 +++++--- .../src/utils/isomorphic/protocolMetainfo.ts | 1 + packages/playwright-core/types/types.d.ts | 6 ++++++ packages/protocol/src/channels.d.ts | 4 ++++ packages/protocol/src/protocol.yml | 3 +++ tests/library/inspector/recorder-api.spec.ts | 12 ++++++++++++ 10 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 7f2fb1dea7583..fa226172acb9f 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2845,6 +2845,12 @@ the place it was paused. This method requires Playwright to be started in a headed mode, with a falsy [`option: BrowserType.launch.headless`] option. ::: +## async method: Page.cancelPickLocator +* since: v1.59 + +Cancels an ongoing [`method: Page.pickLocator`] call by deactivating pick locator mode. +If no pick locator mode is active, this method is a no-op. + ## async method: Page.pickLocator * since: v1.59 - returns: <[Locator]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 818e762e590d6..de129cbd8fae8 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2187,6 +2187,12 @@ export interface Page { */ bringToFront(): Promise; + /** + * Cancels an ongoing [page.pickLocator()](https://playwright.dev/docs/api/class-page#page-pick-locator) call by + * deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. + */ + cancelPickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.check([options])](https://playwright.dev/docs/api/class-locator#locator-check) instead. * Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 7631491213316..42f14dec3bb7d 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -824,6 +824,10 @@ export class Page extends ChannelOwner implements api.Page return this.locator(selector); } + async cancelPickLocator(): Promise { + await this._channel.cancelPickLocator({}); + } + async pdf(options: PDFOptions = {}): Promise { const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams; if (transportOptions.margin) diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 7685b1a3ff90c..442bd5f6c5335 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1534,6 +1534,8 @@ scheme.PagePickLocatorParams = tOptional(tObject({})); scheme.PagePickLocatorResult = tObject({ selector: tString, }); +scheme.PageCancelPickLocatorParams = tOptional(tObject({})); +scheme.PageCancelPickLocatorResult = tOptional(tObject({})); scheme.PageVideoStartParams = tObject({ size: tOptional(tObject({ width: tInt, diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index a834b2bfb5cf8..e9ff5f5499561 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -29,7 +29,6 @@ import { SdkObject } from '../instrumentation'; import { deserializeURLMatch, urlMatches } from '../../utils/isomorphic/urlMatch'; import { PageAgentDispatcher } from './pageAgentDispatcher'; import { Recorder } from '../recorder'; -import { isUnderTest } from '../utils/debug'; import type { Artifact } from '../artifact'; import type { BrowserContext } from '../browserContext'; @@ -347,13 +346,16 @@ export class PageDispatcher extends Dispatcher { - if (!this._page.browserContext._browser.options.headful && !isUnderTest()) - throw new Error('pickLocator() is only available in headed mode'); const recorder = await Recorder.forContext(this._page.browserContext, { omitCallTracking: true, hideToolbar: true }); const selector = await recorder.pickLocator(progress); return { selector }; } + async cancelPickLocator(params: channels.PageCancelPickLocatorParams, progress: Progress): Promise { + const recorder = await Recorder.existingForContext(this._page.browserContext); + recorder?.setMode('none'); + } + async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise { const artifact = await this._page.screencast.startExplicitVideoRecording(params); return { artifact: createVideoDispatcher(this.parentScope(), artifact) }; diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 933a79c3d2e97..5b46f4ac2dbfd 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -147,6 +147,7 @@ export const methodMetainfo = new Map; + /** + * Cancels an ongoing [page.pickLocator()](https://playwright.dev/docs/api/class-page#page-pick-locator) call by + * deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. + */ + cancelPickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.check([options])](https://playwright.dev/docs/api/class-locator#locator-check) instead. * Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 5c155194a9202..61b657e4dec3f 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2147,6 +2147,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { stopCSSCoverage(params?: PageStopCSSCoverageParams, progress?: Progress): Promise; bringToFront(params?: PageBringToFrontParams, progress?: Progress): Promise; pickLocator(params?: PagePickLocatorParams, progress?: Progress): Promise; + cancelPickLocator(params?: PageCancelPickLocatorParams, progress?: Progress): Promise; videoStart(params: PageVideoStartParams, progress?: Progress): Promise; videoStop(params?: PageVideoStopParams, progress?: Progress): Promise; updateSubscription(params: PageUpdateSubscriptionParams, progress?: Progress): Promise; @@ -2658,6 +2659,9 @@ export type PagePickLocatorOptions = {}; export type PagePickLocatorResult = { selector: string, }; +export type PageCancelPickLocatorParams = {}; +export type PageCancelPickLocatorOptions = {}; +export type PageCancelPickLocatorResult = void; export type PageVideoStartParams = { size?: { width: number, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 7043f7453fc69..118ab95dd1cb1 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2069,6 +2069,9 @@ Page: returns: selector: string + cancelPickLocator: + title: Cancel pick locator + videoStart: title: Start video recording group: configuration diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index 4e897caeade34..1b92197ea627e 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -165,3 +165,15 @@ test('page.pickLocator should return locator for picked element', async ({ page const locator = await pickPromise; await expect(locator).toHaveText('Submit'); }); + +test('page.cancelPickLocator should cancel ongoing pickLocator', async ({ page }) => { + await page.setContent(``); + + const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); + const pickPromise = page.pickLocator(); + await scriptReady; + + await page.cancelPickLocator(); + + await expect(pickPromise).rejects.toThrow('Locator picking was cancelled'); +}); From c045a43e19d0c2668fbde96bc1aec562d2216036 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 3 Mar 2026 11:09:01 -0800 Subject: [PATCH 2/3] address comments --- .../src/server/debugController.ts | 18 ++++++--- .../src/server/devtoolsController.ts | 18 +++++---- .../dispatchers/browserContextDispatcher.ts | 3 +- .../src/server/dispatchers/pageDispatcher.ts | 2 +- .../playwright-core/src/server/recorder.ts | 38 +++++++++---------- .../src/server/recorder/recorderApp.ts | 8 ++-- .../src/utils/isomorphic/protocolMetainfo.ts | 4 +- packages/protocol/src/protocol.yml | 2 + tests/library/inspector/recorder-api.spec.ts | 7 ++-- 9 files changed, 55 insertions(+), 45 deletions(-) diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index a9c4aafc7df31..26858e3c07124 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -79,10 +79,12 @@ export class DebugController extends SdkObject { this._generateAutoExpect = !!params.generateAutoExpect; if (params.mode === 'none') { + const promises = []; for (const recorder of await progress.race(this._allRecorders())) { - recorder.hideHighlightedSelector(); - recorder.setMode('none'); + promises.push(recorder.hideHighlightedSelector()); + promises.push(recorder.setMode('none')); } + await Promise.all(promises); return; } @@ -112,20 +114,24 @@ export class DebugController extends SdkObject { if (params.selector) unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid'); const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined; + const promises = []; for (const recorder of await progress.race(this._allRecorders())) { if (ariaTemplate) - recorder.setHighlightedAriaTemplate(ariaTemplate); + promises.push(recorder.setHighlightedAriaTemplate(ariaTemplate)); else if (params.selector) - recorder.setHighlightedSelector(params.selector); + promises.push(recorder.setHighlightedSelector(params.selector)); } + await Promise.all(promises); } async hideHighlight(progress: Progress) { + const promises = []; // Hide all active recorder highlights. for (const recorder of await progress.race(this._allRecorders())) - recorder.hideHighlightedSelector(); + promises.push(recorder.hideHighlightedSelector()); // Hide all locator.highlight highlights. - await Promise.all(this._playwright.allPages().map(p => p.hideHighlight().catch(() => {}))); + promises.push(...this._playwright.allPages().map(p => p.hideHighlight().catch(() => {}))); + await Promise.all(promises); } async resume(progress: Progress) { diff --git a/packages/playwright-core/src/server/devtoolsController.ts b/packages/playwright-core/src/server/devtoolsController.ts index 35c7f19ce1f75..0a85d3498d577 100644 --- a/packages/playwright-core/src/server/devtoolsController.ts +++ b/packages/playwright-core/src/server/devtoolsController.ts @@ -257,20 +257,22 @@ class DevToolsConnection implements Transport, DevToolsChannel { await page.screencast.startScreencast(this, { width: 1280, height: 800, quality: 90 }); } - private _deselectPage() { + private async _deselectPage() { if (!this.selectedPage) return; - this._cancelPicking(); + const promises = []; + promises.push(this._cancelPicking()); eventsHelper.removeEventListeners(this._pageListeners); this._pageListeners = []; - this.selectedPage.screencast.stopScreencast(this); + promises.push(this.selectedPage.screencast.stopScreencast(this)); this.selectedPage = null; this._lastFrameData = null; this._lastViewportSize = null; + await Promise.all(promises); } async pickLocator() { - this._cancelPicking(); + await this._cancelPicking(); const recorder = await Recorder.forContext(this._context, { omitCallTracking: true, hideToolbar: true }); this._recorder = recorder; this._recorderListeners.push( @@ -279,18 +281,18 @@ class DevToolsConnection implements Transport, DevToolsChannel { this._cancelPicking(); }), ); - recorder.setMode('inspecting'); + await recorder.setMode('inspecting'); } async cancelPickLocator() { - this._cancelPicking(); + await this._cancelPicking(); } - private _cancelPicking() { + private async _cancelPicking() { eventsHelper.removeEventListeners(this._recorderListeners); this._recorderListeners = []; if (this._recorder) { - this._recorder.setMode('none'); + await this._recorder.setMode('none'); this._recorder = null; } } diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 1d71932c285de..49ba7cbda3307 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -338,8 +338,7 @@ export class BrowserContextDispatcher extends Dispatcher { const recorder = await Recorder.existingForContext(this._context); - if (recorder) - recorder.setMode('none'); + await recorder?.setMode('none'); } async exposeConsoleApi(params: channels.BrowserContextExposeConsoleApiParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index e9ff5f5499561..96875090ee40d 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -353,7 +353,7 @@ export class PageDispatcher extends Dispatcher { const recorder = await Recorder.existingForContext(this._page.browserContext); - recorder?.setMode('none'); + await recorder?.setMode('none'); } async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 490b7b82f178d..1a1ab677bd8dc 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -198,7 +198,7 @@ export class Recorder extends EventEmitter implements Instrume await this._context.exposeBinding(progress, '__pw_recorderSetMode', false, async ({ frame }, mode: Mode) => { if (frame.parentFrame()) return; - this.setMode(mode); + await this.setMode(mode); }); await this._context.exposeBinding(progress, '__pw_recorderSetOverlayState', false, async ({ frame }, state: OverlayState) => { @@ -252,7 +252,7 @@ export class Recorder extends EventEmitter implements Instrume return this._mode; } - setMode(mode: Mode) { + async setMode(mode: Mode) { if (this._mode === mode) return; this._highlightedElement = {}; @@ -262,7 +262,7 @@ export class Recorder extends EventEmitter implements Instrume this._debugger.setMuted(this._isRecording()); if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1) this._context.pages()[0].bringToFront().catch(() => {}); - this._refreshOverlay(); + await this._refreshOverlay(); } async pickLocator(progress: Progress): Promise { @@ -272,6 +272,8 @@ export class Recorder extends EventEmitter implements Instrume selectorPromise.resolve(elementInfo.selector); }; const onModeChanged = () => { + if (this._mode === 'inspecting') + return; recorderChangedState = true; selectorPromise.reject(new Error('Locator picking was cancelled')); }; @@ -279,21 +281,17 @@ export class Recorder extends EventEmitter implements Instrume recorderChangedState = true; selectorPromise.reject(new Error('Context was closed')); }; - // Register listeners after setMode() to avoid consuming the ModeChanged - // event that fires synchronously from our own setMode('inspecting') call. - this.setMode('inspecting'); const listeners: RegisteredListener[] = [ eventsHelper.addEventListener(this, RecorderEvent.ElementPicked, onElementPicked), eventsHelper.addEventListener(this, RecorderEvent.ModeChanged, onModeChanged), eventsHelper.addEventListener(this, RecorderEvent.ContextClosed, onContextClosed), ]; try { - return await progress.race(selectorPromise); + return await progress.race((async () => (await Promise.all([selectorPromise, this.setMode('inspecting')]))[0])()); } finally { - // Remove listeners before setMode('none') to avoid triggering onModeChanged. eventsHelper.removeEventListeners(listeners); if (!recorderChangedState) - this.setMode('none'); + await this.setMode('none'); } } @@ -302,23 +300,23 @@ export class Recorder extends EventEmitter implements Instrume return page?.mainFrame().url(); } - setHighlightedSelector(selector: string) { + async setHighlightedSelector(selector: string) { this._highlightedElement = { selector: locatorOrSelectorAsSelector(this._currentLanguage, selector, this._context.selectors().testIdAttributeName()) }; - this._refreshOverlay(); + await this._refreshOverlay(); } - setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) { + async setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) { this._highlightedElement = { ariaTemplate }; - this._refreshOverlay(); + await this._refreshOverlay(); } step() { this._debugger.resume(true); } - setLanguage(language: Language) { + async setLanguage(language: Language) { this._currentLanguage = language; - this._refreshOverlay(); + await this._refreshOverlay(); } resume() { @@ -337,9 +335,9 @@ export class Recorder extends EventEmitter implements Instrume this._debugger.resume(false); } - hideHighlightedSelector() { + async hideHighlightedSelector() { this._highlightedElement = {}; - this._refreshOverlay(); + await this._refreshOverlay(); } pausedSourceId() { @@ -386,11 +384,13 @@ export class Recorder extends EventEmitter implements Instrume } } - private _refreshOverlay() { + private async _refreshOverlay() { + const promises = []; for (const page of this._context.pages()) { for (const frame of page.frames()) - frame.evaluateExpression('window.__pw_refreshOverlay()').catch(() => {}); + promises.push(frame.evaluateExpression('window.__pw_refreshOverlay()').catch(() => {})); } + await Promise.all(promises); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index a9184bfcc6183..52713cab70336 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -142,7 +142,7 @@ export class RecorderApp { if (source) { if (source.isRecorded) this._selectedGeneratorId = source.id; - this._recorder.setLanguage(source.language); + await this._recorder.setLanguage(source.language); } }, setAutoExpect: async (params: { autoExpect: boolean }) => { @@ -150,7 +150,7 @@ export class RecorderApp { this._updateActions(); }, setMode: async (params: { mode: Mode }) => { - this._recorder.setMode(params.mode); + await this._recorder.setMode(params.mode); }, resume: async () => { this._recorder.resume(); @@ -163,9 +163,9 @@ export class RecorderApp { }, highlightRequested: async (params: { selector?: string; ariaTemplate?: AriaTemplateNode }) => { if (params.selector) - this._recorder.setHighlightedSelector(params.selector); + await this._recorder.setHighlightedSelector(params.selector); if (params.ariaTemplate) - this._recorder.setHighlightedAriaTemplate(params.ariaTemplate); + await this._recorder.setHighlightedAriaTemplate(params.ariaTemplate); }, }; diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 5b46f4ac2dbfd..79c4dd96242b8 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -146,8 +146,8 @@ export const methodMetainfo = new Map Date: Tue, 3 Mar 2026 11:48:24 -0800 Subject: [PATCH 3/3] address comments --- packages/playwright-core/src/server/recorder.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 1a1ab677bd8dc..c66a0cb27bb3c 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -287,7 +287,13 @@ export class Recorder extends EventEmitter implements Instrume eventsHelper.addEventListener(this, RecorderEvent.ContextClosed, onContextClosed), ]; try { - return await progress.race((async () => (await Promise.all([selectorPromise, this.setMode('inspecting')]))[0])()); + const doPickLocator = async () => { + // Prevent unhandled rejection in case of cancellation during setMode + selectorPromise.catch(() => {}); + await this.setMode('inspecting'); + return await selectorPromise; + }; + return await progress.race(doPickLocator()); } finally { eventsHelper.removeEventListeners(listeners); if (!recorderChangedState) @@ -385,12 +391,8 @@ export class Recorder extends EventEmitter implements Instrume } private async _refreshOverlay() { - const promises = []; - for (const page of this._context.pages()) { - for (const frame of page.frames()) - promises.push(frame.evaluateExpression('window.__pw_refreshOverlay()').catch(() => {})); - } - await Promise.all(promises); + await Promise.all(this._context.pages().map( + page => page.safeNonStallingEvaluateInAllFrames('window.__pw_refreshOverlay()', 'main'))); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {