diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index cf18620d5e2cc..33171d04727fb 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -150,6 +150,21 @@ var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe")); Additional locator to match. +## async method: Locator.ariaRef +* since: v1.60 +* langs: js +- returns: <[null]|[string]> + +Returns the aria ref (for example `e1`, `e2`) assigned to this element by the most recent aria snapshot, or `null` +if no ref has been assigned yet. Call [`method: Locator.ariaSnapshot`] or [`method: Page.ariaSnapshot`] before this +method to ensure a ref is available. + +### option: Locator.ariaRef.timeout = %%-input-timeout-%% +* since: v1.60 + +### option: Locator.ariaRef.timeout = %%-input-timeout-js-%% +* since: v1.60 + ## async method: Locator.ariaSnapshot * since: v1.49 - returns: <[string]> @@ -1415,8 +1430,14 @@ Attribute name to get the value for. ### option: Locator.getByTitle.exact = %%-locator-get-by-text-exact-%% +## async method: Locator.hideHighlight +* since: v1.60 + +Hide element highlight added with Highlight the corresponding element(s) on the screen. Useful for debugging, don't commit the code that uses [`method: Locator.highlight`]. + ## async method: Locator.highlight * since: v1.20 +- returns: <[Disposable]> Highlight the corresponding element(s) on the screen. Useful for debugging, don't commit the code that uses [`method: Locator.highlight`]. diff --git a/packages/injected/src/highlight.ts b/packages/injected/src/highlight.ts index f0356ec5a7fbe..6508bbca60735 100644 --- a/packages/injected/src/highlight.ts +++ b/packages/injected/src/highlight.ts @@ -63,6 +63,7 @@ export class Highlight { private _injectedScript: InjectedScript; private _rafRequest: number | undefined; private _language: Language = 'javascript'; + private _elementHighlightSelectors = new Map(); constructor(injectedScript: InjectedScript) { this._injectedScript = injectedScript; @@ -123,22 +124,53 @@ export class Highlight { this._language = language; } - runHighlightOnRaf(selector: ParsedSelector) { + addElementHighlight(selector: ParsedSelector) { + const key = stringifySelector(selector); + if (this._elementHighlightSelectors.has(key)) + return; + this._elementHighlightSelectors.set(key, selector); + this._ensureElementHighlightRaf(); + } + + removeElementHighlight(selector: ParsedSelector) { + const key = stringifySelector(selector); + if (!this._elementHighlightSelectors.delete(key)) + return; + if (this._elementHighlightSelectors.size === 0) { + if (this._rafRequest) { + this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); + this._rafRequest = undefined; + } + this.clearHighlight(); + } + } + + private _ensureElementHighlightRaf() { if (this._rafRequest) - this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); - const elements = this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement); - const locator = asLocator(this._language, stringifySelector(selector)); - const color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f'; - this.updateHighlight(elements.map((element, index) => { - const suffix = elements.length > 1 ? ` [${index + 1} of ${elements.length}]` : ''; - return { element, color, tooltipText: locator + suffix }; - })); - this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(() => this.runHighlightOnRaf(selector)); + return; + const tick = () => { + const entries: HighlightEntry[] = []; + for (const selector of this._elementHighlightSelectors.values()) { + const elements = this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement); + const locator = asLocator(this._language, stringifySelector(selector)); + const color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f'; + for (let i = 0; i < elements.length; ++i) { + const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ''; + entries.push({ element: elements[i], color, tooltipText: locator + suffix }); + } + } + this.updateHighlight(entries); + this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(tick); + }; + this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(tick); } uninstall() { - if (this._rafRequest) + if (this._rafRequest) { this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); + this._rafRequest = undefined; + } + this._elementHighlightSelectors.clear(); this._glassPaneElement.remove(); } diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 732155168dfa7..c1a1dc4f6cea7 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1313,12 +1313,14 @@ export class InjectedScript { return this._highlight; } - highlight(selector: ParsedSelector) { - if (!this._highlight) { - this._highlight = new Highlight(this); - this._highlight.install(); - } - this._highlight.runHighlightOnRaf(selector); + addHighlight(selector: ParsedSelector) { + const highlight = this._ensureHighlight(); + highlight.addElementHighlight(selector); + } + + removeHighlight(selector: ParsedSelector) { + const highlight = this._ensureHighlight(); + highlight.removeElementHighlight(selector); } setScreencastAnnotation(annotation: { point?: channels.Point, box?: channels.Rect, actionTitle?: string, duration?: number, position?: string, fontSize?: number } | null) { diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 11382328002a0..fa70041e4ec06 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -160,6 +160,7 @@ export const methodMetainfo = new Map([ ['Frame.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }], ['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pause: true, }], ['Frame.addStyleTag', { title: 'Add style tag', snapshot: true, pause: true, }], + ['Frame.ariaRef', { internal: true, }], ['Frame.ariaSnapshot', { title: 'Aria snapshot', group: 'getter', }], ['Frame.blur', { title: 'Blur', slowMo: true, snapshot: true, pause: true, }], ['Frame.check', { title: 'Check', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], @@ -174,7 +175,8 @@ export const methodMetainfo = new Map([ ['Frame.focus', { title: 'Focus', slowMo: true, snapshot: true, pause: true, }], ['Frame.frameElement', { title: 'Get frame element', group: 'getter', }], ['Frame.resolveSelector', { internal: true, }], - ['Frame.highlight', { title: 'Highlight element', group: 'configuration', }], + ['Frame.highlight', { internal: true, }], + ['Frame.hideHighlight', { internal: true, }], ['Frame.getAttribute', { title: 'Get attribute "{name}"', snapshot: true, pause: true, group: 'getter', }], ['Frame.goto', { title: 'Navigate to "{url}"', slowMo: true, snapshot: true, pause: true, }], ['Frame.hover', { title: 'Hover', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 713631848c121..451fd0dced8bd 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -12664,6 +12664,24 @@ export interface Locator { */ and(locator: Locator): Locator; + /** + * Returns the aria ref (for example `e1`, `e2`) assigned to this element by the most recent aria snapshot, or `null` + * if no ref has been assigned yet. Call + * [locator.ariaSnapshot([options])](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot) or + * [page.ariaSnapshot([options])](https://playwright.dev/docs/api/class-page#page-aria-snapshot) before this method to + * ensure a ref is available. + * @param options + */ + ariaRef(options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * Captures the aria snapshot of the given element. Read more about [aria snapshots](https://playwright.dev/docs/aria-snapshots) and * [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot) @@ -13794,11 +13812,17 @@ export interface Locator { exact?: boolean; }): Locator; + /** + * Hide element highlight added with Highlight the corresponding element(s) on the screen. Useful for debugging, don't + * commit the code that uses [locator.highlight()](https://playwright.dev/docs/api/class-locator#locator-highlight). + */ + hideHighlight(): Promise; + /** * Highlight the corresponding element(s) on the screen. Useful for debugging, don't commit the code that uses * [locator.highlight()](https://playwright.dev/docs/api/class-locator#locator-highlight). */ - highlight(): Promise; + highlight(): Promise; /** * Hover over the matching element. diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 93936e1b469f4..30f490ee0ceed 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -317,6 +317,10 @@ export class Frame extends ChannelOwner implements api.Fr return await this._channel.highlight({ selector }); } + async _hideHighlight(selector: string) { + return await this._channel.hideHighlight({ selector }); + } + locator(selector: string, options?: LocatorOptions): Locator { return new Locator(this, selector, options); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index d6ad043bcc378..8e93de4dabe1a 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -20,6 +20,7 @@ import { escapeForTextSelector } from '@isomorphic/stringUtils'; import { isString } from '@isomorphic/rtti'; import { monotonicTime } from '@isomorphic/time'; import { ElementHandle } from './elementHandle'; +import { DisposableStub } from './disposable'; import type { Frame } from './frame'; import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; @@ -151,7 +152,12 @@ export class Locator implements api.Locator { } async highlight() { - return await this._frame._highlight(this._selector); + await this._frame._highlight(this._selector); + return new DisposableStub(() => this.hideHighlight()); + } + + async hideHighlight() { + await this._frame._hideHighlight(this._selector); } locator(selectorOrLocator: string | Locator, options?: Omit): Locator { @@ -313,6 +319,11 @@ export class Locator implements api.Locator { return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), { title: 'Screenshot', timeout: options.timeout }); } + async ariaRef(options: TimeoutOptions = {}): Promise { + const { ref } = await this._frame._channel.ariaRef({ selector: this._selector, timeout: this._frame._timeout(options) }); + return ref ?? null; + } + async ariaSnapshot(options: TimeoutOptions & { mode?: 'ai' | 'default', depth?: number } = {}): Promise { const result = await this._frame._channel.ariaSnapshot({ timeout: this._frame._timeout(options), mode: options.mode, selector: this._selector, depth: options.depth }); return result.snapshot; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 49bda468d37ea..4c248ce9295a0 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1648,6 +1648,13 @@ scheme.FrameAddStyleTagParams = tObject({ scheme.FrameAddStyleTagResult = tObject({ element: tChannel(['ElementHandle']), }); +scheme.FrameAriaRefParams = tObject({ + selector: tString, + timeout: tFloat, +}); +scheme.FrameAriaRefResult = tObject({ + ref: tOptional(tString), +}); scheme.FrameAriaSnapshotParams = tObject({ mode: tOptional(tEnum(['ai', 'default'])), track: tOptional(tString), @@ -1769,6 +1776,10 @@ scheme.FrameHighlightParams = tObject({ selector: tString, }); scheme.FrameHighlightResult = tOptional(tObject({})); +scheme.FrameHideHighlightParams = tObject({ + selector: tString, +}); +scheme.FrameHideHighlightResult = tOptional(tObject({})); scheme.FrameGetAttributeParams = tObject({ selector: tString, strict: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index c79cc9b111e0b..5b205e00c60d4 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -135,6 +135,10 @@ export class FrameDispatcher extends Dispatcher { + return await this._frame.ariaRef(progress, params.selector); + } + async ariaSnapshot(params: channels.FrameAriaSnapshotParams, progress: Progress): Promise { return await this._frame.ariaSnapshot(progress, params); } @@ -261,7 +265,11 @@ export class FrameDispatcher extends Dispatcher { - return await this._frame.highlight(progress, params.selector); + return await this._frame.addHighlight(progress, params.selector); + } + + async hideHighlight(params: channels.FrameHideHighlightParams, progress: Progress): Promise { + return await this._frame.removeHighlight(progress, params.selector); } async expect(params: channels.FrameExpectParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index a342f2e27ba0d..b64011e586399 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1319,12 +1319,21 @@ export class Frame extends SdkObject { }, undefined, options, scope); } - async highlight(progress: Progress, selector: string) { + async addHighlight(progress: Progress, selector: string) { const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector)); if (!resolved) return; return await progress.race(resolved.injected.evaluate((injected, { info }) => { - return injected.highlight(info.parsed); + return injected.addHighlight(info.parsed); + }, { info: resolved.info })); + } + + async removeHighlight(progress: Progress, selector: string) { + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector)); + if (!resolved) + return; + return await progress.race(resolved.injected.evaluate((injected, { info }) => { + return injected.removeHighlight(info.parsed); }, { info: resolved.info })); } @@ -1746,6 +1755,15 @@ export class Frame extends SdkObject { }, { source, arg }); } + async ariaRef(progress: Progress, selector: string): Promise<{ ref?: string }> { + const ref = await this._retryWithProgressIfNotConnected(progress, selector, { strict: true, performActionPreChecks: true }, async (progress, handle) => { + return await progress.race(handle.evaluateInUtility(([injected, element]) => { + return (element as any)._ariaRef?.ref as string | undefined; + }, {})); + }); + return { ref }; + } + async ariaSnapshot(progress: Progress, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, selector?: string, depth?: number } = {}): Promise<{ snapshot: string }> { if (options.selector && options.track) throw new Error('Cannot specify both selector and track options'); diff --git a/packages/playwright-core/src/tools/backend/devtools.ts b/packages/playwright-core/src/tools/backend/devtools.ts index 2b934f177967e..1100e341c0127 100644 --- a/packages/playwright-core/src/tools/backend/devtools.ts +++ b/packages/playwright-core/src/tools/backend/devtools.ts @@ -15,7 +15,8 @@ */ import * as z from 'zod'; -import { defineTool } from './tool'; +import { defineTabTool, defineTool } from './tool'; +import { elementSchema } from './snapshot'; const resume = defineTool({ capability: 'devtools', @@ -64,4 +65,58 @@ const resume = defineTool({ }, }); -export default [resume]; +const pickLocator = defineTabTool({ + capability: 'devtools', + schema: { + name: 'browser_pick_locator', + title: 'Pick element locator', + description: 'Wait for the user to pick an element in the browser and return its ref and locator.', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async (tab, params, response) => { + const locator = await tab.page.pickLocator(); + // Regenerate aria refs so the picked element has an aria ref we can read below. + await tab.page.ariaSnapshot({ mode: 'ai' }); + const ref = await locator.ariaRef(); + const resolved = await locator.normalize(); + response.addTextResult(`ref: ${ref ?? '(none)'}\nlocator: ${resolved.toString()}`); + }, +}); + +const highlight = defineTabTool({ + capability: 'devtools', + schema: { + name: 'browser_highlight', + title: 'Highlight element', + description: 'Show a persistent highlight overlay around the element on the page.', + inputSchema: elementSchema, + type: 'readOnly', + }, + + handle: async (tab, params, response) => { + const { locator, resolved } = await tab.refLocator(params); + await (await locator.normalize()).highlight(); + response.addTextResult(`Highlighted ${resolved}`); + }, +}); + +const hideHighlight = defineTabTool({ + capability: 'devtools', + schema: { + name: 'browser_hide_highlight', + title: 'Hide element highlight', + description: 'Remove a highlight overlay previously added for the element.', + inputSchema: elementSchema, + type: 'readOnly', + }, + + handle: async (tab, params, response) => { + const { locator, resolved } = await tab.refLocator(params); + await (await locator.normalize()).hideHighlight(); + response.addTextResult(`Hid highlight for ${resolved}`); + }, +}); + +export default [resume, pickLocator, highlight, hideHighlight]; diff --git a/packages/playwright-core/src/tools/backend/snapshot.ts b/packages/playwright-core/src/tools/backend/snapshot.ts index 4f3cb3cb39840..39a75d89440c7 100644 --- a/packages/playwright-core/src/tools/backend/snapshot.ts +++ b/packages/playwright-core/src/tools/backend/snapshot.ts @@ -166,7 +166,7 @@ const selectOption = defineTabTool({ }, }); -const pickLocator = defineTabTool({ +const generateLocator = defineTabTool({ capability: 'testing', schema: { name: 'browser_generate_locator', @@ -225,7 +225,7 @@ export default [ drag, hover, selectOption, - pickLocator, + generateLocator, check, uncheck, ]; diff --git a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md index f648e927f64ca..fac8c80391812 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md +++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md @@ -158,6 +158,13 @@ playwright-cli tracing-stop playwright-cli video-start video.webm playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 playwright-cli video-stop + +# wait for the user to pick an element in the browser, print its ref and locator +playwright-cli pick + +# show a persistent highlight overlay for an element, or remove it with --hide +playwright-cli highlight e5 +playwright-cli highlight e5 --hide ``` ## Raw output @@ -338,6 +345,21 @@ playwright-cli tracing-stop playwright-cli close ``` +## Example: Interactive element inspection + +Ask the user to point at an element in the browser, then keep it visible while you work on it: + +```bash +playwright-cli open https://example.com +# blocks until the user clicks an element; prints `ref: eN` and the locator +playwright-cli pick +# keep the picked element highlighted while iterating +playwright-cli highlight e5 +# ... inspect, generate code, etc. ... +playwright-cli highlight e5 --hide +playwright-cli close +``` + ## Specific tasks * **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md) diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index d2cd60bc5dabe..5398be49d0635 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -355,6 +355,29 @@ const snapshot = declareCommand({ toolParams: ({ filename, element, depth }) => ({ filename, ...asRef(element), depth }), }); +const pick = declareCommand({ + name: 'pick', + description: 'Wait for the user to pick an element in the browser and print its ref and locator', + category: 'devtools', + args: z.object({}), + toolName: 'browser_pick_locator', + toolParams: () => ({}), +}); + +const highlight = declareCommand({ + name: 'highlight', + description: 'Show (or with --hide, remove) a highlight overlay for an element', + category: 'devtools', + args: z.object({ + target: z.string().describe('Exact target element reference from the page snapshot, or a unique element selector'), + }), + options: z.object({ + hide: z.boolean().optional().describe('Hide a previously added highlight for this element'), + }), + toolName: ({ hide }) => hide ? 'browser_hide_highlight' : 'browser_highlight', + toolParams: ({ target }) => ({ ...asRef(target) }), +}); + const evaluate = declareCommand({ name: 'eval', description: 'Evaluate JavaScript expression on page or element', @@ -1054,6 +1077,8 @@ const commandsArray: AnyCommandSchema[] = [ pauseAt, resume, stepOver, + pick, + highlight, // session category sessionList, diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 713631848c121..451fd0dced8bd 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12664,6 +12664,24 @@ export interface Locator { */ and(locator: Locator): Locator; + /** + * Returns the aria ref (for example `e1`, `e2`) assigned to this element by the most recent aria snapshot, or `null` + * if no ref has been assigned yet. Call + * [locator.ariaSnapshot([options])](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot) or + * [page.ariaSnapshot([options])](https://playwright.dev/docs/api/class-page#page-aria-snapshot) before this method to + * ensure a ref is available. + * @param options + */ + ariaRef(options?: { + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * Captures the aria snapshot of the given element. Read more about [aria snapshots](https://playwright.dev/docs/aria-snapshots) and * [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot) @@ -13794,11 +13812,17 @@ export interface Locator { exact?: boolean; }): Locator; + /** + * Hide element highlight added with Highlight the corresponding element(s) on the screen. Useful for debugging, don't + * commit the code that uses [locator.highlight()](https://playwright.dev/docs/api/class-locator#locator-highlight). + */ + hideHighlight(): Promise; + /** * Highlight the corresponding element(s) on the screen. Useful for debugging, don't commit the code that uses * [locator.highlight()](https://playwright.dev/docs/api/class-locator#locator-highlight). */ - highlight(): Promise; + highlight(): Promise; /** * Hover over the matching element. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 30824236fd8c3..213fb8e47045d 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2804,6 +2804,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { evalOnSelectorAll(params: FrameEvalOnSelectorAllParams, progress?: Progress): Promise; addScriptTag(params: FrameAddScriptTagParams, progress?: Progress): Promise; addStyleTag(params: FrameAddStyleTagParams, progress?: Progress): Promise; + ariaRef(params: FrameAriaRefParams, progress?: Progress): Promise; ariaSnapshot(params: FrameAriaSnapshotParams, progress?: Progress): Promise; blur(params: FrameBlurParams, progress?: Progress): Promise; check(params: FrameCheckParams, progress?: Progress): Promise; @@ -2819,6 +2820,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { frameElement(params?: FrameFrameElementParams, progress?: Progress): Promise; resolveSelector(params: FrameResolveSelectorParams, progress?: Progress): Promise; highlight(params: FrameHighlightParams, progress?: Progress): Promise; + hideHighlight(params: FrameHideHighlightParams, progress?: Progress): Promise; getAttribute(params: FrameGetAttributeParams, progress?: Progress): Promise; goto(params: FrameGotoParams, progress?: Progress): Promise; hover(params: FrameHoverParams, progress?: Progress): Promise; @@ -2910,6 +2912,16 @@ export type FrameAddStyleTagOptions = { export type FrameAddStyleTagResult = { element: ElementHandleChannel, }; +export type FrameAriaRefParams = { + selector: string, + timeout: number, +}; +export type FrameAriaRefOptions = { + +}; +export type FrameAriaRefResult = { + ref?: string, +}; export type FrameAriaSnapshotParams = { mode?: 'ai' | 'default', track?: string, @@ -3100,6 +3112,13 @@ export type FrameHighlightOptions = { }; export type FrameHighlightResult = void; +export type FrameHideHighlightParams = { + selector: string, +}; +export type FrameHideHighlightOptions = { + +}; +export type FrameHideHighlightResult = void; export type FrameGetAttributeParams = { selector: string, strict?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index afec91adadd6c..1b6cb8ea8fe5f 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2299,6 +2299,14 @@ Frame: snapshot: true pause: true + ariaRef: + internal: true + parameters: + selector: string + timeout: float + returns: + ref: string? + ariaSnapshot: title: Aria snapshot group: getter @@ -2517,8 +2525,12 @@ Frame: resolvedSelector: string highlight: - title: Highlight element - group: configuration + internal: true + parameters: + selector: string + + hideHighlight: + internal: true parameters: selector: string diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 308c36b221ebd..1d34ca7d4f829 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -159,3 +159,60 @@ test('video-chapter', async ({ cli, server }) => { expect(output).toContain(`Chapter 'Introduction' added.`); await cli('video-stop'); }); + +test('pick', async ({ cdpServer, cli, server }) => { + server.setContent('/', ``, 'text/html'); + const browserContext = await cdpServer.start(); + const [page] = browserContext.pages(); + await page.goto(server.PREFIX); + + await cli('attach', `--cdp=${cdpServer.endpoint}`); + await cli('snapshot'); + + const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); + const pickPromise = cli('pick'); + 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 { output } = await pickPromise; + expect(output).toContain(`ref: e2`); + expect(output).toContain(`locator: getByRole('button', { name: 'Submit' })`); +}); + +test('highlight', async ({ cdpServer, cli, server }) => { + server.setContent('/', ``, 'text/html'); + const browserContext = await cdpServer.start(); + const [page] = browserContext.pages(); + await page.goto(server.PREFIX); + + await cli('attach', `--cdp=${cdpServer.endpoint}`); + await cli('snapshot'); + + const { output } = await cli('highlight', 'e2'); + expect(output).toContain(`Highlighted getByRole('button', { name: 'Submit' })`); + + const highlight = page.locator('x-pw-highlight'); + const tooltip = page.locator('x-pw-tooltip-line'); + await expect(highlight).toBeVisible(); + await expect(tooltip).toHaveText(`getByRole('button', { name: 'Submit' })`); + expect(await highlight.boundingBox()).toEqual(await page.getByRole('button', { name: 'Submit' }).boundingBox()); +}); + +test('highlight --hide', async ({ cdpServer, cli, server }) => { + server.setContent('/', ``, 'text/html'); + const browserContext = await cdpServer.start(); + const [page] = browserContext.pages(); + await page.goto(server.PREFIX); + + await cli('attach', `--cdp=${cdpServer.endpoint}`); + await cli('snapshot'); + + await cli('highlight', 'e2'); + await expect(page.locator('x-pw-highlight')).toBeVisible(); + + const { output } = await cli('highlight', 'e2', '--hide'); + expect(output).toContain(`Hid highlight for getByRole('button', { name: 'Submit' })`); + await expect(page.locator('x-pw-highlight')).toHaveCount(0); +}); diff --git a/tests/mcp/devtools.spec.ts b/tests/mcp/devtools.spec.ts new file mode 100644 index 0000000000000..b7d37e35edf88 --- /dev/null +++ b/tests/mcp/devtools.spec.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures'; + +test.use({ mcpCaps: ['devtools'] }); + +test('browser_pick_locator', async ({ cdpServer, startClient, server }) => { + server.setContent('/', ``, 'text/html'); + const browserContext = await cdpServer.start(); + const [page] = browserContext.pages(); + await page.goto(server.PREFIX); + + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + await client.callTool({ name: 'browser_snapshot' }); + + const scriptReady = page.waitForEvent('console', msg => msg.text() === 'Recorder script ready for test'); + const pickPromise = client.callTool({ name: 'browser_pick_locator' }); + 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); + + expect(await pickPromise).toHaveResponse({ + result: `ref: e2\nlocator: getByRole('button', { name: 'Submit' })`, + }); +}); + +test('browser_highlight', async ({ cdpServer, startClient, server }) => { + server.setContent('/', ``, 'text/html'); + const browserContext = await cdpServer.start(); + const [page] = browserContext.pages(); + await page.goto(server.PREFIX); + + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + await client.callTool({ name: 'browser_snapshot' }); + + expect(await client.callTool({ + name: 'browser_highlight', + arguments: { element: 'Submit button', ref: 'e2' }, + })).toHaveResponse({ + result: `Highlighted getByRole('button', { name: 'Submit' })`, + }); + + const highlight = page.locator('x-pw-highlight'); + const tooltip = page.locator('x-pw-tooltip-line'); + await expect(highlight).toBeVisible(); + await expect(tooltip).toHaveText(`getByRole('button', { name: 'Submit' })`); + expect(await highlight.boundingBox()).toEqual(await page.getByRole('button', { name: 'Submit' }).boundingBox()); +}); + +test('browser_hide_highlight', async ({ cdpServer, startClient, server }) => { + server.setContent('/', ``, 'text/html'); + const browserContext = await cdpServer.start(); + const [page] = browserContext.pages(); + await page.goto(server.PREFIX); + + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + await client.callTool({ name: 'browser_snapshot' }); + + await client.callTool({ + name: 'browser_highlight', + arguments: { element: 'Submit button', ref: 'e2' }, + }); + await expect(page.locator('x-pw-highlight')).toBeVisible(); + + expect(await client.callTool({ + name: 'browser_hide_highlight', + arguments: { element: 'Submit button', ref: 'e2' }, + })).toHaveResponse({ + result: `Hid highlight for getByRole('button', { name: 'Submit' })`, + }); + await expect(page.locator('x-pw-highlight')).toHaveCount(0); +});