From 9802f73ae3395f56318bd2c9d41a426f3607eca8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 2 Mar 2026 16:26:46 -0800 Subject: [PATCH 1/3] feat: add Page.pickLocator() API --- docs/src/api/class-page.md | 18 +++++++++ packages/playwright-client/types/types.d.ts | 17 ++++++++ packages/playwright-core/src/client/page.ts | 5 +++ .../playwright-core/src/protocol/validator.ts | 4 ++ .../src/server/dispatchers/pageDispatcher.ts | 40 +++++++++++++++++++ .../src/utils/isomorphic/protocolMetainfo.ts | 1 + packages/playwright-core/types/types.d.ts | 17 ++++++++ packages/protocol/src/channels.d.ts | 6 +++ packages/protocol/src/protocol.yml | 5 +++ tests/library/inspector/recorder-api.spec.ts | 14 +++++++ 10 files changed, 127 insertions(+) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 56ef466cba5d3..2ee73acb25e9a 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2845,6 +2845,24 @@ 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.pickLocator +* since: v1.59 +- returns: <[Locator]> + +Launches the Playwright Inspector in pick locator mode, waits for the user to pick an element on the page, and returns +a [Locator] for that element. Once the user clicks an element, the inspector closes and the [Locator] is returned. + +:::note +This method requires Playwright to be started in a headed mode. +::: + +**Usage** + +```js +const locator = await page.pickLocator(); +console.log(locator); +``` + ## async method: Page.pdf * since: v1.8 - returns: <[Buffer]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index db283f4217641..70b8320389292 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -3904,6 +3904,23 @@ export interface Page { width?: string|number; }): Promise; + /** + * Launches the Playwright Inspector in pick locator mode, waits for the user to pick an element on the page, and + * returns a [Locator](https://playwright.dev/docs/api/class-locator) for that element. Once the user clicks an + * element, the inspector closes and the [Locator](https://playwright.dev/docs/api/class-locator) is returned. + * + * **NOTE** This method requires Playwright to be started in a headed mode. + * + * **Usage** + * + * ```js + * const locator = await page.pickLocator(); + * console.log(locator); + * ``` + * + */ + pickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * 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 e556bde327526..7631491213316 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -819,6 +819,11 @@ export class Page extends ChannelOwner implements api.Page this._browserContext.setDefaultTimeout(defaultTimeout); } + async pickLocator(): Promise { + const { selector } = await this._channel.pickLocator({}); + return this.locator(selector); + } + 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 ad440997298ea..7685b1a3ff90c 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1530,6 +1530,10 @@ scheme.PageStopCSSCoverageResult = tObject({ }); scheme.PageBringToFrontParams = tOptional(tObject({})); scheme.PageBringToFrontResult = tOptional(tObject({})); +scheme.PagePickLocatorParams = tOptional(tObject({})); +scheme.PagePickLocatorResult = tObject({ + selector: tString, +}); 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 8c29a14b04dcd..07bb97a1f4775 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -28,6 +28,13 @@ import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; import { SdkObject } from '../instrumentation'; import { deserializeURLMatch, urlMatches } from '../../utils/isomorphic/urlMatch'; import { PageAgentDispatcher } from './pageAgentDispatcher'; +import { Recorder, RecorderEvent } from '../recorder'; +import { ManualPromise } from '../../utils/isomorphic/manualPromise'; +import { eventsHelper } from '../utils/eventsHelper'; +import { isUnderTest } from '../utils/debug'; + +import type { ElementInfo } from '@recorder/recorderTypes'; +import type { RegisteredListener } from '../utils/eventsHelper'; import type { Artifact } from '../artifact'; import type { BrowserContext } from '../browserContext'; @@ -344,6 +351,39 @@ 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 }); + const selectorPromise = new ManualPromise(); + let recorderChangedState = false; + const onElementPicked = (elementInfo: ElementInfo) => { + selectorPromise.resolve(elementInfo.selector); + }; + const onModeChanged = () => { + recorderChangedState = true; + selectorPromise.reject(new Error('Locator picking was cancelled')); + }; + const onContextClosed = () => { + recorderChangedState = true; + selectorPromise.reject(new Error('Context was closed')); + }; + recorder.setMode('inspecting'); + const listeners: RegisteredListener[] = [ + eventsHelper.addEventListener(recorder, RecorderEvent.ElementPicked, onElementPicked), + eventsHelper.addEventListener(recorder, RecorderEvent.ModeChanged, onModeChanged), + eventsHelper.addEventListener(recorder, RecorderEvent.ContextClosed, onContextClosed), + ]; + try { + const selector = await progress.race(selectorPromise); + return { selector }; + } finally { + eventsHelper.removeEventListeners(listeners); + if (!recorderChangedState) + 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 9990b7f57ef00..933a79c3d2e97 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -146,6 +146,7 @@ export const methodMetainfo = new Map; + /** + * Launches the Playwright Inspector in pick locator mode, waits for the user to pick an element on the page, and + * returns a [Locator](https://playwright.dev/docs/api/class-locator) for that element. Once the user clicks an + * element, the inspector closes and the [Locator](https://playwright.dev/docs/api/class-locator) is returned. + * + * **NOTE** This method requires Playwright to be started in a headed mode. + * + * **Usage** + * + * ```js + * const locator = await page.pickLocator(); + * console.log(locator); + * ``` + * + */ + pickLocator(): Promise; + /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * 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 13f74e5af8bae..5c155194a9202 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2146,6 +2146,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { startCSSCoverage(params: PageStartCSSCoverageParams, progress?: Progress): Promise; stopCSSCoverage(params?: PageStopCSSCoverageParams, progress?: Progress): Promise; bringToFront(params?: PageBringToFrontParams, progress?: Progress): Promise; + pickLocator(params?: PagePickLocatorParams, progress?: Progress): Promise; videoStart(params: PageVideoStartParams, progress?: Progress): Promise; videoStop(params?: PageVideoStopParams, progress?: Progress): Promise; updateSubscription(params: PageUpdateSubscriptionParams, progress?: Progress): Promise; @@ -2652,6 +2653,11 @@ export type PageStopCSSCoverageResult = { export type PageBringToFrontParams = {}; export type PageBringToFrontOptions = {}; export type PageBringToFrontResult = void; +export type PagePickLocatorParams = {}; +export type PagePickLocatorOptions = {}; +export type PagePickLocatorResult = { + selector: string, +}; export type PageVideoStartParams = { size?: { width: number, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index d07e9f4d43741..7043f7453fc69 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2064,6 +2064,11 @@ Page: bringToFront: title: Bring to front + pickLocator: + title: Pick locator + returns: + selector: string + 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 d195e8cfb8ce6..4e897caeade34 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -151,3 +151,17 @@ test('should disable recorder', async ({ context }) => { await page.getByRole('button', { name: 'Submit' }).click(); expect(log.action('click')).toHaveLength(2); }); + +test('page.pickLocator should return locator for picked element', async ({ page }) => { + await page.setContent(``); + + const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); + const pickPromise = page.pickLocator(); + await scriptReady; + + const box = await page.getByRole('button', { name: 'Submit' }).boundingBox(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + + const locator = await pickPromise; + await expect(locator).toHaveText('Submit'); +}); From 382dd0cba881763ec7855af57880cbb676893de1 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 2 Mar 2026 16:39:12 -0800 Subject: [PATCH 2/3] hide toolbar --- docs/src/api/class-page.md | 4 ++-- packages/playwright-client/types/types.d.ts | 6 +++--- .../src/server/dispatchers/pageDispatcher.ts | 2 +- packages/playwright-core/types/types.d.ts | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 2ee73acb25e9a..7f2fb1dea7583 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2849,8 +2849,8 @@ This method requires Playwright to be started in a headed mode, with a falsy [`o * since: v1.59 - returns: <[Locator]> -Launches the Playwright Inspector in pick locator mode, waits for the user to pick an element on the page, and returns -a [Locator] for that element. Once the user clicks an element, the inspector closes and the [Locator] is returned. +Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. +Once the user clicks an element, the mode is deactivated and the [Locator] for the picked element is returned. :::note This method requires Playwright to be started in a headed mode. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 70b8320389292..818e762e590d6 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -3905,9 +3905,9 @@ export interface Page { }): Promise; /** - * Launches the Playwright Inspector in pick locator mode, waits for the user to pick an element on the page, and - * returns a [Locator](https://playwright.dev/docs/api/class-locator) for that element. Once the user clicks an - * element, the inspector closes and the [Locator](https://playwright.dev/docs/api/class-locator) is returned. + * Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. + * Once the user clicks an element, the mode is deactivated and the + * [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned. * * **NOTE** This method requires Playwright to be started in a headed mode. * diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 07bb97a1f4775..e2e5d27413dca 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -354,7 +354,7 @@ 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 }); + const recorder = await Recorder.forContext(this._page.browserContext, { omitCallTracking: true, hideToolbar: true }); const selectorPromise = new ManualPromise(); let recorderChangedState = false; const onElementPicked = (elementInfo: ElementInfo) => { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 70b8320389292..818e762e590d6 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -3905,9 +3905,9 @@ export interface Page { }): Promise; /** - * Launches the Playwright Inspector in pick locator mode, waits for the user to pick an element on the page, and - * returns a [Locator](https://playwright.dev/docs/api/class-locator) for that element. Once the user clicks an - * element, the inspector closes and the [Locator](https://playwright.dev/docs/api/class-locator) is returned. + * Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. + * Once the user clicks an element, the mode is deactivated and the + * [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned. * * **NOTE** This method requires Playwright to be started in a headed mode. * From fb9f3d8f274606cfb21fcecf066e9716ca7d9447 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 2 Mar 2026 16:57:12 -0800 Subject: [PATCH 3/3] move logic out from dispatcher --- .../src/server/dispatchers/pageDispatcher.ts | 36 ++----------------- .../playwright-core/src/server/recorder.ts | 35 ++++++++++++++++++ 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index e2e5d27413dca..a834b2bfb5cf8 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -28,14 +28,9 @@ import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; import { SdkObject } from '../instrumentation'; import { deserializeURLMatch, urlMatches } from '../../utils/isomorphic/urlMatch'; import { PageAgentDispatcher } from './pageAgentDispatcher'; -import { Recorder, RecorderEvent } from '../recorder'; -import { ManualPromise } from '../../utils/isomorphic/manualPromise'; -import { eventsHelper } from '../utils/eventsHelper'; +import { Recorder } from '../recorder'; import { isUnderTest } from '../utils/debug'; -import type { ElementInfo } from '@recorder/recorderTypes'; -import type { RegisteredListener } from '../utils/eventsHelper'; - import type { Artifact } from '../artifact'; import type { BrowserContext } from '../browserContext'; import type { CRCoverage } from '../chromium/crCoverage'; @@ -355,33 +350,8 @@ export class PageDispatcher extends Dispatcher(); - let recorderChangedState = false; - const onElementPicked = (elementInfo: ElementInfo) => { - selectorPromise.resolve(elementInfo.selector); - }; - const onModeChanged = () => { - recorderChangedState = true; - selectorPromise.reject(new Error('Locator picking was cancelled')); - }; - const onContextClosed = () => { - recorderChangedState = true; - selectorPromise.reject(new Error('Context was closed')); - }; - recorder.setMode('inspecting'); - const listeners: RegisteredListener[] = [ - eventsHelper.addEventListener(recorder, RecorderEvent.ElementPicked, onElementPicked), - eventsHelper.addEventListener(recorder, RecorderEvent.ModeChanged, onModeChanged), - eventsHelper.addEventListener(recorder, RecorderEvent.ContextClosed, onContextClosed), - ]; - try { - const selector = await progress.race(selectorPromise); - return { selector }; - } finally { - eventsHelper.removeEventListeners(listeners); - if (!recorderChangedState) - recorder.setMode('none'); - } + const selector = await recorder.pickLocator(progress); + return { selector }; } 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 99b3a8a022605..490b7b82f178d 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -24,6 +24,8 @@ import { buildFullSelector, generateFrameSelector, metadataToCallLog } from './r import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; import { stringifySelector } from '../utils/isomorphic/selectorParser'; import { ProgressController } from './progress'; +import { ManualPromise } from '../utils/isomorphic/manualPromise'; + import { RecorderSignalProcessor } from './recorder/recorderSignalProcessor'; import * as rawRecorderSource from './../generated/pollingRecorderSource'; import { eventsHelper, monotonicTime } from './../utils'; @@ -35,6 +37,7 @@ import type { Language } from './codegen/types'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { Point } from '../utils/isomorphic/types'; import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; +import type { Progress } from './progress'; import type * as channels from '@protocol/channels'; import type * as actions from '@recorder/actions'; import type { CallLog, CallLogStatus, ElementInfo, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes'; @@ -262,6 +265,38 @@ export class Recorder extends EventEmitter implements Instrume this._refreshOverlay(); } + async pickLocator(progress: Progress): Promise { + const selectorPromise = new ManualPromise(); + let recorderChangedState = false; + const onElementPicked = (elementInfo: ElementInfo) => { + selectorPromise.resolve(elementInfo.selector); + }; + const onModeChanged = () => { + recorderChangedState = true; + selectorPromise.reject(new Error('Locator picking was cancelled')); + }; + const onContextClosed = () => { + 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); + } finally { + // Remove listeners before setMode('none') to avoid triggering onModeChanged. + eventsHelper.removeEventListeners(listeners); + if (!recorderChangedState) + this.setMode('none'); + } + } + url(): string | undefined { const page = this._context.pages()[0]; return page?.mainFrame().url();