Skip to content

Commit cd528a2

Browse files
authored
feat: add Page.pickLocator() API (#39486)
1 parent 7e5dd5e commit cd528a2

11 files changed

Lines changed: 132 additions & 0 deletions

File tree

docs/src/api/class-page.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2845,6 +2845,24 @@ the place it was paused.
28452845
This method requires Playwright to be started in a headed mode, with a falsy [`option: BrowserType.launch.headless`] option.
28462846
:::
28472847

2848+
## async method: Page.pickLocator
2849+
* since: v1.59
2850+
- returns: <[Locator]>
2851+
2852+
Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator.
2853+
Once the user clicks an element, the mode is deactivated and the [Locator] for the picked element is returned.
2854+
2855+
:::note
2856+
This method requires Playwright to be started in a headed mode.
2857+
:::
2858+
2859+
**Usage**
2860+
2861+
```js
2862+
const locator = await page.pickLocator();
2863+
console.log(locator);
2864+
```
2865+
28482866
## async method: Page.pdf
28492867
* since: v1.8
28502868
- returns: <[Buffer]>

packages/playwright-client/types/types.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3904,6 +3904,23 @@ export interface Page {
39043904
width?: string|number;
39053905
}): Promise<Buffer>;
39063906

3907+
/**
3908+
* Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator.
3909+
* Once the user clicks an element, the mode is deactivated and the
3910+
* [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned.
3911+
*
3912+
* **NOTE** This method requires Playwright to be started in a headed mode.
3913+
*
3914+
* **Usage**
3915+
*
3916+
* ```js
3917+
* const locator = await page.pickLocator();
3918+
* console.log(locator);
3919+
* ```
3920+
*
3921+
*/
3922+
pickLocator(): Promise<Locator>;
3923+
39073924
/**
39083925
* **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press)
39093926
* instead. Read more about [locators](https://playwright.dev/docs/locators).

packages/playwright-core/src/client/page.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,11 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
819819
this._browserContext.setDefaultTimeout(defaultTimeout);
820820
}
821821

822+
async pickLocator(): Promise<Locator> {
823+
const { selector } = await this._channel.pickLocator({});
824+
return this.locator(selector);
825+
}
826+
822827
async pdf(options: PDFOptions = {}): Promise<Buffer> {
823828
const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams;
824829
if (transportOptions.margin)

packages/playwright-core/src/protocol/validator.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,6 +1530,10 @@ scheme.PageStopCSSCoverageResult = tObject({
15301530
});
15311531
scheme.PageBringToFrontParams = tOptional(tObject({}));
15321532
scheme.PageBringToFrontResult = tOptional(tObject({}));
1533+
scheme.PagePickLocatorParams = tOptional(tObject({}));
1534+
scheme.PagePickLocatorResult = tObject({
1535+
selector: tString,
1536+
});
15331537
scheme.PageVideoStartParams = tObject({
15341538
size: tOptional(tObject({
15351539
width: tInt,

packages/playwright-core/src/server/dispatchers/pageDispatcher.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
2828
import { SdkObject } from '../instrumentation';
2929
import { deserializeURLMatch, urlMatches } from '../../utils/isomorphic/urlMatch';
3030
import { PageAgentDispatcher } from './pageAgentDispatcher';
31+
import { Recorder } from '../recorder';
32+
import { isUnderTest } from '../utils/debug';
3133

3234
import type { Artifact } from '../artifact';
3335
import type { BrowserContext } from '../browserContext';
@@ -344,6 +346,14 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
344346
await progress.race(this._page.bringToFront());
345347
}
346348

349+
async pickLocator(params: channels.PagePickLocatorParams, progress: Progress): Promise<channels.PagePickLocatorResult> {
350+
if (!this._page.browserContext._browser.options.headful && !isUnderTest())
351+
throw new Error('pickLocator() is only available in headed mode');
352+
const recorder = await Recorder.forContext(this._page.browserContext, { omitCallTracking: true, hideToolbar: true });
353+
const selector = await recorder.pickLocator(progress);
354+
return { selector };
355+
}
356+
347357
async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise<channels.PageVideoStartResult> {
348358
const artifact = await this._page.screencast.startExplicitVideoRecording(params);
349359
return { artifact: createVideoDispatcher(this.parentScope(), artifact) };

packages/playwright-core/src/server/recorder.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { buildFullSelector, generateFrameSelector, metadataToCallLog } from './r
2424
import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
2525
import { stringifySelector } from '../utils/isomorphic/selectorParser';
2626
import { ProgressController } from './progress';
27+
import { ManualPromise } from '../utils/isomorphic/manualPromise';
28+
2729
import { RecorderSignalProcessor } from './recorder/recorderSignalProcessor';
2830
import * as rawRecorderSource from './../generated/pollingRecorderSource';
2931
import { eventsHelper, monotonicTime } from './../utils';
@@ -35,6 +37,7 @@ import type { Language } from './codegen/types';
3537
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
3638
import type { Point } from '../utils/isomorphic/types';
3739
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
40+
import type { Progress } from './progress';
3841
import type * as channels from '@protocol/channels';
3942
import type * as actions from '@recorder/actions';
4043
import type { CallLog, CallLogStatus, ElementInfo, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes';
@@ -262,6 +265,38 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
262265
this._refreshOverlay();
263266
}
264267

268+
async pickLocator(progress: Progress): Promise<string> {
269+
const selectorPromise = new ManualPromise<string>();
270+
let recorderChangedState = false;
271+
const onElementPicked = (elementInfo: ElementInfo) => {
272+
selectorPromise.resolve(elementInfo.selector);
273+
};
274+
const onModeChanged = () => {
275+
recorderChangedState = true;
276+
selectorPromise.reject(new Error('Locator picking was cancelled'));
277+
};
278+
const onContextClosed = () => {
279+
recorderChangedState = true;
280+
selectorPromise.reject(new Error('Context was closed'));
281+
};
282+
// Register listeners after setMode() to avoid consuming the ModeChanged
283+
// event that fires synchronously from our own setMode('inspecting') call.
284+
this.setMode('inspecting');
285+
const listeners: RegisteredListener[] = [
286+
eventsHelper.addEventListener(this, RecorderEvent.ElementPicked, onElementPicked),
287+
eventsHelper.addEventListener(this, RecorderEvent.ModeChanged, onModeChanged),
288+
eventsHelper.addEventListener(this, RecorderEvent.ContextClosed, onContextClosed),
289+
];
290+
try {
291+
return await progress.race(selectorPromise);
292+
} finally {
293+
// Remove listeners before setMode('none') to avoid triggering onModeChanged.
294+
eventsHelper.removeEventListeners(listeners);
295+
if (!recorderChangedState)
296+
this.setMode('none');
297+
}
298+
}
299+
265300
url(): string | undefined {
266301
const page = this._context.pages()[0];
267302
return page?.mainFrame().url();

packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const methodMetainfo = new Map<string, { internal?: boolean, title?: stri
146146
['Page.startCSSCoverage', { title: 'Start CSS coverage', group: 'configuration', }],
147147
['Page.stopCSSCoverage', { title: 'Stop CSS coverage', group: 'configuration', }],
148148
['Page.bringToFront', { title: 'Bring to front', }],
149+
['Page.pickLocator', { title: 'Pick locator', }],
149150
['Page.videoStart', { title: 'Start video recording', group: 'configuration', }],
150151
['Page.videoStop', { title: 'Stop video recording', group: 'configuration', }],
151152
['Page.updateSubscription', { internal: true, }],

packages/playwright-core/types/types.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3904,6 +3904,23 @@ export interface Page {
39043904
width?: string|number;
39053905
}): Promise<Buffer>;
39063906

3907+
/**
3908+
* Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator.
3909+
* Once the user clicks an element, the mode is deactivated and the
3910+
* [Locator](https://playwright.dev/docs/api/class-locator) for the picked element is returned.
3911+
*
3912+
* **NOTE** This method requires Playwright to be started in a headed mode.
3913+
*
3914+
* **Usage**
3915+
*
3916+
* ```js
3917+
* const locator = await page.pickLocator();
3918+
* console.log(locator);
3919+
* ```
3920+
*
3921+
*/
3922+
pickLocator(): Promise<Locator>;
3923+
39073924
/**
39083925
* **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press)
39093926
* instead. Read more about [locators](https://playwright.dev/docs/locators).

packages/protocol/src/channels.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2146,6 +2146,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
21462146
startCSSCoverage(params: PageStartCSSCoverageParams, progress?: Progress): Promise<PageStartCSSCoverageResult>;
21472147
stopCSSCoverage(params?: PageStopCSSCoverageParams, progress?: Progress): Promise<PageStopCSSCoverageResult>;
21482148
bringToFront(params?: PageBringToFrontParams, progress?: Progress): Promise<PageBringToFrontResult>;
2149+
pickLocator(params?: PagePickLocatorParams, progress?: Progress): Promise<PagePickLocatorResult>;
21492150
videoStart(params: PageVideoStartParams, progress?: Progress): Promise<PageVideoStartResult>;
21502151
videoStop(params?: PageVideoStopParams, progress?: Progress): Promise<PageVideoStopResult>;
21512152
updateSubscription(params: PageUpdateSubscriptionParams, progress?: Progress): Promise<PageUpdateSubscriptionResult>;
@@ -2652,6 +2653,11 @@ export type PageStopCSSCoverageResult = {
26522653
export type PageBringToFrontParams = {};
26532654
export type PageBringToFrontOptions = {};
26542655
export type PageBringToFrontResult = void;
2656+
export type PagePickLocatorParams = {};
2657+
export type PagePickLocatorOptions = {};
2658+
export type PagePickLocatorResult = {
2659+
selector: string,
2660+
};
26552661
export type PageVideoStartParams = {
26562662
size?: {
26572663
width: number,

packages/protocol/src/protocol.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,6 +2064,11 @@ Page:
20642064
bringToFront:
20652065
title: Bring to front
20662066

2067+
pickLocator:
2068+
title: Pick locator
2069+
returns:
2070+
selector: string
2071+
20672072
videoStart:
20682073
title: Start video recording
20692074
group: configuration

0 commit comments

Comments
 (0)