diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 56ef466cba5d3..7f2fb1dea7583 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]> + +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. +::: + +**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..818e762e590d6 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; + /** + * 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. + * + * **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..a834b2bfb5cf8 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -28,6 +28,8 @@ import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; 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'; @@ -344,6 +346,14 @@ 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 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/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(); 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; + /** + * 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. + * + * **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'); +});