Skip to content

Commit ea4ea1f

Browse files
authored
feat(mcp): emit structured snapshot in --json responses (#42098)
1 parent f8b14a3 commit ea4ea1f

21 files changed

Lines changed: 805 additions & 25 deletions

File tree

docs/src/api/class-locator.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,61 @@ When `true`, appends each element's bounding box as `[box=x,y,width,height]` to
239239
relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
240240
Defaults to `false`.
241241

242+
## async method: Locator.ariaSnapshotJSON
243+
* since: v1.63
244+
* langs: js
245+
- returns: <[Serializable]>
246+
247+
Captures the aria snapshot of the given element as a free form JSON object.
248+
249+
**Usage**
250+
251+
```js
252+
await page.getByRole('list').ariaSnapshotJSON();
253+
```
254+
255+
**Details**
256+
257+
This method returns the same tree as [`method: Locator.ariaSnapshot`], serialized as a JSON value instead of YAML markup.
258+
The result is a list of nodes, each node being either a plain string with static text, or an object with the following properties:
259+
* `role` <[string]> Aria role of the element.
260+
* `name` <[string]> Accessible name of the element, if any.
261+
* `text` <[string]> Text content of the element, when it is the only child.
262+
* `children` <[Array]> Child nodes and text fragments.
263+
* Boolean and value properties for element state flags: `checked`, `disabled`, `expanded`, `active`, `invalid`, `level`, `pressed` and `selected`.
264+
* Additional element properties, for example `url` for links and `placeholder` for text boxes.
265+
* `ref` <[string]> Element reference for AI-optimized snapshots.
266+
* `cursor` <[string]> Set to `"pointer"` for clickable elements in AI-optimized snapshots.
267+
* `box` <[Object]> Bounding box of the element when [`option: Locator.ariaSnapshotJSON.boxes`] is set.
268+
269+
### option: Locator.ariaSnapshotJSON.mode
270+
* since: v1.63
271+
- `mode` <[AriaSnapshotMode]<"ai"|"default">>
272+
273+
When set to `"ai"`, returns a snapshot optimized for AI consumption. Defaults to `"default"`. See details in [`method: Locator.ariaSnapshot`].
274+
275+
### option: Locator.ariaSnapshotJSON.timeout = %%-input-timeout-%%
276+
* since: v1.63
277+
278+
### option: Locator.ariaSnapshotJSON.timeout = %%-input-timeout-js-%%
279+
* since: v1.63
280+
281+
### option: Locator.ariaSnapshotJSON.signal = %%-input-signal-%%
282+
283+
### option: Locator.ariaSnapshotJSON.depth
284+
* since: v1.63
285+
- `depth` <[int]>
286+
287+
When specified, limits the depth of the snapshot.
288+
289+
### option: Locator.ariaSnapshotJSON.boxes
290+
* since: v1.63
291+
- `boxes` <[boolean]>
292+
293+
When `true`, includes each element's bounding box as a `box` property with `x`, `y`, `width` and `height`. Coordinates are
294+
relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
295+
Defaults to `false`.
296+
242297
## async method: Locator.blur
243298
* since: v1.28
244299

docs/src/api/class-page.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4427,6 +4427,43 @@ When `true`, appends each element's bounding box as `[box=x,y,width,height]` to
44274427
relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
44284428
Defaults to `false`.
44294429

4430+
## async method: Page.ariaSnapshotJSON
4431+
* since: v1.63
4432+
* langs: js
4433+
- returns: <[Serializable]>
4434+
4435+
Captures the aria snapshot of the page as a free form JSON object.
4436+
Returns the same tree as [`method: Page.ariaSnapshot`], serialized as a JSON value instead of YAML markup.
4437+
See [`method: Locator.ariaSnapshotJSON`] for the details of the format.
4438+
4439+
### option: Page.ariaSnapshotJSON.mode
4440+
* since: v1.63
4441+
- `mode` <[AriaSnapshotMode]<"ai"|"default">>
4442+
4443+
When set to `"ai"`, returns a snapshot optimized for AI consumption: including element references like `[ref=e2]` and snapshots of `<iframe>`s. Defaults to `"default"`.
4444+
4445+
### option: Page.ariaSnapshotJSON.timeout = %%-input-timeout-%%
4446+
* since: v1.63
4447+
4448+
### option: Page.ariaSnapshotJSON.timeout = %%-input-timeout-js-%%
4449+
* since: v1.63
4450+
4451+
### option: Page.ariaSnapshotJSON.signal = %%-input-signal-%%
4452+
4453+
### option: Page.ariaSnapshotJSON.depth
4454+
* since: v1.63
4455+
- `depth` <[int]>
4456+
4457+
When specified, limits the depth of the snapshot.
4458+
4459+
### option: Page.ariaSnapshotJSON.boxes
4460+
* since: v1.63
4461+
- `boxes` <[boolean]>
4462+
4463+
When `true`, includes each element's bounding box as a `box` property with `x`, `y`, `width` and `height`. Coordinates are
4464+
relative to the viewport, in CSS pixels, as returned by [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
4465+
Defaults to `false`.
4466+
44304467
## async method: Page.tap
44314468
* since: v1.8
44324469
* discouraged: Use locator-based [`method: Locator.tap`] instead. Read more about [locators](../locators.md).

packages/injected/src/ariaSnapshot.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,74 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
583583
return { text: lines.join('\n'), iframeDepths };
584584
}
585585

586+
export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): { json: aria.AriaSnapshotJSON, iframeDepths: Record<string, number> } {
587+
const options = toInternalOptions(publicOptions);
588+
const iframeDepths: Record<string, number> = {};
589+
590+
const visit = (ariaNode: aria.AriaNode, depth: number, renderCursorPointer: boolean): aria.AriaNodeJSON => {
591+
if (ariaNode.role === 'iframe' && ariaNode.ref)
592+
iframeDepths[ariaNode.ref] = depth;
593+
594+
const node: aria.AriaNodeJSON = { role: ariaNode.role };
595+
if (ariaNode.name)
596+
node.name = ariaNode.name;
597+
if (ariaNode.checked === 'mixed' || ariaNode.checked === true)
598+
node.checked = ariaNode.checked;
599+
if (ariaNode.disabled)
600+
node.disabled = true;
601+
if (ariaNode.expanded)
602+
node.expanded = true;
603+
if (ariaNode.active && options.renderActive)
604+
node.active = true;
605+
if (ariaNode.invalid)
606+
node.invalid = ariaNode.invalid;
607+
if (ariaNode.level)
608+
node.level = ariaNode.level;
609+
if (ariaNode.pressed === 'mixed' || ariaNode.pressed === true)
610+
node.pressed = ariaNode.pressed;
611+
if (ariaNode.selected === true)
612+
node.selected = true;
613+
if (ariaNode.ref) {
614+
node.ref = ariaNode.ref;
615+
if (renderCursorPointer && aria.hasPointerCursor(ariaNode))
616+
node.cursor = 'pointer';
617+
}
618+
if (options.renderBoxes) {
619+
const element = ariaNodeElement(ariaNode);
620+
if (element) {
621+
const r = element.getBoundingClientRect();
622+
node.box = { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) };
623+
}
624+
}
625+
for (const [name, value] of Object.entries(ariaNode.props))
626+
node[name] = value;
627+
628+
const singleTextChild = ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' ? ariaNode.children[0] : undefined;
629+
const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth;
630+
if (singleTextChild !== undefined) {
631+
node.text = singleTextChild;
632+
} else if (!isAtDepthLimit && ariaNode.children.length) {
633+
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode);
634+
node.children = ariaNode.children.map(child => {
635+
if (typeof child === 'string')
636+
return child;
637+
return visit(child, depth + 1, renderCursorPointer && !inCursorPointer);
638+
});
639+
}
640+
return node;
641+
};
642+
643+
const json: aria.AriaSnapshotJSON = [];
644+
const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root];
645+
for (const nodeToRender of nodesToRender) {
646+
if (typeof nodeToRender === 'string')
647+
json.push(nodeToRender);
648+
else
649+
json.push(visit(nodeToRender, 0, !!options.renderCursorPointer));
650+
}
651+
return { json, iframeDepths };
652+
}
653+
586654
function convertToBestGuessRegex(text: string): string {
587655
const dynamicContent = [
588656
// 550e8400-e29b-41d4-a716-446655440000

packages/injected/src/injectedScript.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { splitTestIdAttributeNames } from '@isomorphic/locatorUtils';
2020
import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser';
2121
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils';
2222

23-
import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree, findNewElement } from './ariaSnapshot';
23+
import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree, renderAriaTreeAsJSON, findNewElement } from './ariaSnapshot';
2424
import { beginDOMCaches, enclosingShadowRootOrDocument, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils';
2525
import { Highlight } from './highlight';
2626
import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils';
@@ -33,7 +33,7 @@ import { XPathEngine } from './xpathSelectorEngine';
3333
import { ConsoleAPI } from './consoleApi';
3434
import { UtilityScript } from './utilityScript';
3535

36-
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
36+
import type { AriaSnapshotJSON, AriaTemplateNode } from '@isomorphic/ariaSnapshot';
3737
import type { CSSComplexSelectorList } from '@isomorphic/cssParser';
3838
import type { Language } from '@isomorphic/locatorGenerators';
3939
import type { AttributeSelectorPart, NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '@isomorphic/selectorParser';
@@ -328,6 +328,16 @@ export class InjectedScript {
328328
return { text: rendered.text, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths };
329329
}
330330

331+
ariaSnapshotJSON(node: Node, options: AriaTreeOptions & { depth?: number }): { json: AriaSnapshotJSON, iframeRefs: string[], iframeDepths: Record<string, number> } {
332+
if (node.nodeType !== Node.ELEMENT_NODE)
333+
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
334+
options = { ...options, refPrefix: this._frameSeq && options.mode === 'ai' ? 'f' + this._frameSeq : '' };
335+
const ariaSnapshot = generateAriaTree(node as Element, options);
336+
const rendered = renderAriaTreeAsJSON(ariaSnapshot, options);
337+
this._lastAriaSnapshotForQuery = ariaSnapshot;
338+
return { json: rendered.json, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths };
339+
}
340+
331341
ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map<Element, string> } {
332342
const tree = generateAriaTree(this.document.body, { mode: 'ai' });
333343
const { text: ariaSnapshot } = renderAriaTree(tree, { mode: 'ai' });

packages/isomorphic/ariaSnapshot.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ export function hasPointerCursor(ariaNode: AriaNode): boolean {
5555
return ariaNode.box.cursor === 'pointer';
5656
}
5757

58+
// Free form JSON serialization of the aria tree. Nodes are either static text
59+
// fragments or plain objects with the role, name, state flags and children.
60+
export type AriaNodeJSON = {
61+
[key: string]: string | number | boolean | object | undefined;
62+
children?: (AriaNodeJSON | string)[];
63+
};
64+
65+
export type AriaSnapshotJSON = (AriaNodeJSON | string)[];
66+
5867
// We pass parsed template between worlds using JSON, make it easy.
5968
export type AriaRegex = { pattern: string };
6069

packages/isomorphic/protocolMetainfo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
126126
['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pause: true, }],
127127
['Frame.addStyleTag', { title: 'Add style tag', snapshot: true, pause: true, }],
128128
['Frame.ariaSnapshot', { title: 'Aria snapshot', group: 'getter', }],
129+
['Frame.ariaSnapshotJSON', { title: 'Aria snapshot JSON', group: 'getter', }],
129130
['Frame.blur', { title: 'Blur', slowMo: true, snapshot: true, pause: true, }],
130131
['Frame.check', { title: 'Check', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],
131132
['Frame.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }],

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,6 +2138,54 @@ export interface Page {
21382138
timeout?: number;
21392139
}): Promise<string>;
21402140

2141+
/**
2142+
* Captures the aria snapshot of the page as a free form JSON object. Returns the same tree as
2143+
* [page.ariaSnapshot([options])](https://playwright.dev/docs/api/class-page#page-aria-snapshot), serialized as a JSON
2144+
* value instead of YAML markup. See
2145+
* [locator.ariaSnapshotJSON([options])](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot-json) for
2146+
* the details of the format.
2147+
* @param options
2148+
*/
2149+
ariaSnapshotJSON(options?: {
2150+
/**
2151+
* When `true`, includes each element's bounding box as a `box` property with `x`, `y`, `width` and `height`.
2152+
* Coordinates are relative to the viewport, in CSS pixels, as returned by
2153+
* [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
2154+
* Defaults to `false`.
2155+
*/
2156+
boxes?: boolean;
2157+
2158+
/**
2159+
* When specified, limits the depth of the snapshot.
2160+
*/
2161+
depth?: number;
2162+
2163+
/**
2164+
* When set to `"ai"`, returns a snapshot optimized for AI consumption: including element references like `[ref=e2]`
2165+
* and snapshots of `<iframe>`s. Defaults to `"default"`.
2166+
*/
2167+
mode?: "ai"|"default";
2168+
2169+
/**
2170+
* Allows to cancel the operation using an
2171+
* [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). If the signal is aborted, the
2172+
* operation will be aborted and throw an error. Note that providing a signal does not disable the default timeout,
2173+
* which can be changed using
2174+
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout)
2175+
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout); pass
2176+
* `timeout: 0` to disable the timeout entirely.
2177+
*/
2178+
signal?: AbortSignal;
2179+
2180+
/**
2181+
* Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout`
2182+
* option in the config, or by using the
2183+
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout)
2184+
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
2185+
*/
2186+
timeout?: number;
2187+
}): Promise<Serializable>;
2188+
21412189
/**
21422190
* Brings page to front (activates tab).
21432191
*/
@@ -14350,6 +14398,74 @@ export interface Locator {
1435014398
timeout?: number;
1435114399
}): Promise<string>;
1435214400

14401+
/**
14402+
* Captures the aria snapshot of the given element as a free form JSON object.
14403+
*
14404+
* **Usage**
14405+
*
14406+
* ```js
14407+
* await page.getByRole('list').ariaSnapshotJSON();
14408+
* ```
14409+
*
14410+
* **Details**
14411+
*
14412+
* This method returns the same tree as
14413+
* [locator.ariaSnapshot([options])](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot), serialized
14414+
* as a JSON value instead of YAML markup. The result is a list of nodes, each node being either a plain string with
14415+
* static text, or an object with the following properties:
14416+
* - `role` <[string]> Aria role of the element.
14417+
* - `name` <[string]> Accessible name of the element, if any.
14418+
* - `text` <[string]> Text content of the element, when it is the only child.
14419+
* - `children` <[Array]> Child nodes and text fragments.
14420+
* - Boolean and value properties for element state flags: `checked`, `disabled`, `expanded`, `active`, `invalid`,
14421+
* `level`, `pressed` and `selected`.
14422+
* - Additional element properties, for example `url` for links and `placeholder` for text boxes.
14423+
* - `ref` <[string]> Element reference for AI-optimized snapshots.
14424+
* - `cursor` <[string]> Set to `"pointer"` for clickable elements in AI-optimized snapshots.
14425+
* - `box` <[Object]> Bounding box of the element when
14426+
* [`boxes`](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot-json-option-boxes) is set.
14427+
* @param options
14428+
*/
14429+
ariaSnapshotJSON(options?: {
14430+
/**
14431+
* When `true`, includes each element's bounding box as a `box` property with `x`, `y`, `width` and `height`.
14432+
* Coordinates are relative to the viewport, in CSS pixels, as returned by
14433+
* [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect).
14434+
* Defaults to `false`.
14435+
*/
14436+
boxes?: boolean;
14437+
14438+
/**
14439+
* When specified, limits the depth of the snapshot.
14440+
*/
14441+
depth?: number;
14442+
14443+
/**
14444+
* When set to `"ai"`, returns a snapshot optimized for AI consumption. Defaults to `"default"`. See details in
14445+
* [locator.ariaSnapshot([options])](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot).
14446+
*/
14447+
mode?: "ai"|"default";
14448+
14449+
/**
14450+
* Allows to cancel the operation using an
14451+
* [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). If the signal is aborted, the
14452+
* operation will be aborted and throw an error. Note that providing a signal does not disable the default timeout,
14453+
* which can be changed using
14454+
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout)
14455+
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout); pass
14456+
* `timeout: 0` to disable the timeout entirely.
14457+
*/
14458+
signal?: AbortSignal;
14459+
14460+
/**
14461+
* Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout`
14462+
* option in the config, or by using the
14463+
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout)
14464+
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
14465+
*/
14466+
timeout?: number;
14467+
}): Promise<Serializable>;
14468+
1435314469
/**
1435414470
* Calls [blur](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/blur) on the element.
1435514471
* @param options

packages/playwright-core/src/client/channels.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,6 +2179,7 @@ export interface FrameChannel extends FrameEventTarget, Channel {
21792179
addScriptTag(params: FrameAddScriptTagParams, options: TimeoutOptions): Promise<FrameAddScriptTagResult>;
21802180
addStyleTag(params: FrameAddStyleTagParams, options: TimeoutOptions): Promise<FrameAddStyleTagResult>;
21812181
ariaSnapshot(params: FrameAriaSnapshotParams, options: TimeoutOptions): Promise<FrameAriaSnapshotResult>;
2182+
ariaSnapshotJSON(params: FrameAriaSnapshotJSONParams, options: TimeoutOptions): Promise<FrameAriaSnapshotJSONResult>;
21822183
blur(params: FrameBlurParams, options: TimeoutOptions): Promise<FrameBlurResult>;
21832184
check(params: FrameCheckParams, options: TimeoutOptions): Promise<FrameCheckResult>;
21842185
click(params: FrameClickParams, options: TimeoutOptions): Promise<FrameClickResult>;
@@ -2301,6 +2302,21 @@ export type FrameAriaSnapshotOptions = {
23012302
export type FrameAriaSnapshotResult = {
23022303
snapshot: string,
23032304
};
2305+
export type FrameAriaSnapshotJSONParams = {
2306+
mode?: 'ai' | 'default',
2307+
selector?: string,
2308+
depth?: number,
2309+
boxes?: boolean,
2310+
};
2311+
export type FrameAriaSnapshotJSONOptions = {
2312+
mode?: 'ai' | 'default',
2313+
selector?: string,
2314+
depth?: number,
2315+
boxes?: boolean,
2316+
};
2317+
export type FrameAriaSnapshotJSONResult = {
2318+
snapshot: any,
2319+
};
23042320
export type FrameBlurParams = {
23052321
selector: string,
23062322
strict?: boolean,

0 commit comments

Comments
 (0)