Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -2498,6 +2498,12 @@ Returns an accessibility snapshot of the element's subtree optimized for AI cons
### option: Locator.snapshotForAI.timeout = %%-input-timeout-js-%%
* since: v1.59

### option: Locator.snapshotForAI.depth
* since: v1.59
- `depth` <[int]>

When specified, limits the depth of the snapshot.

## async method: Locator.tap
* since: v1.14

Expand Down
6 changes: 6 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -4232,6 +4232,12 @@ Returns an accessibility snapshot of the page optimized for AI consumption.
When specified, enables incremental snapshots. Subsequent calls with the same track name will return
an incremental snapshot containing only changes since the last call.

### option: Page.snapshotForAI.depth
* since: v1.59
- `depth` <[int]>

When specified, limits the depth of the snapshot.

## async method: Page.tap
* since: v1.8
* discouraged: Use locator-based [`method: Locator.tap`] instead. Read more about [locators](../locators.md).
Expand Down
37 changes: 24 additions & 13 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type AriaTreeOptions = {
mode: 'ai' | 'expect' | 'codegen' | 'autoexpect';
refPrefix?: string;
doNotRenderActive?: boolean;
depth?: number;
};

type InternalOptions = {
Expand Down Expand Up @@ -563,6 +564,10 @@ function filterSnapshotDiff(nodes: (aria.AriaNode | string)[], statusMap: Map<ar
return result;
}

function indent(depth: number): string {
return ' '.repeat(depth);
}

export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): string {
const options = toInternalOptions(publicOptions);
const lines: string[] = [];
Expand All @@ -576,10 +581,12 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
if (previousSnapshot)
nodesToRender = filterSnapshotDiff(nodesToRender, statusMap);

const visitText = (text: string, indent: string) => {
const visitText = (text: string, depth: number) => {
if (publicOptions.depth && depth > publicOptions.depth)
return;
const escaped = yamlEscapeValueIfNeeded(renderString(text));
if (escaped)
lines.push(indent + '- text: ' + escaped);
lines.push(indent(depth) + '- text: ' + escaped);
};

const createKey = (ariaNode: aria.AriaNode, renderCursorPointer: boolean): string => {
Expand Down Expand Up @@ -623,19 +630,24 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
};

const visit = (ariaNode: aria.AriaNode, indent: string, renderCursorPointer: boolean) => {
const visit = (ariaNode: aria.AriaNode, depth: number, renderCursorPointer: boolean) => {
if (publicOptions.depth && depth > publicOptions.depth)
return;

// Replace the whole subtree with a single reference when possible.
if (statusMap.get(ariaNode) === 'same' && ariaNode.ref) {
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`);
return;
}

// When producing a diff, add <changed> marker to all diff roots.
const isDiffRoot = !!previousSnapshot && !indent;
const escapedKey = indent + '- ' + (isDiffRoot ? '<changed> ' : '') + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
const isDiffRoot = !!previousSnapshot && !depth;
const escapedKey = indent(depth) + '- ' + (isDiffRoot ? '<changed> ' : '') + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth;
const hasNoChildren = !singleInlinedTextChild && (!ariaNode.children.length || isAtDepthLimit);

if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) {
if (hasNoChildren && !Object.keys(ariaNode.props).length) {
// Leaf node without children.
lines.push(escapedKey);
} else if (singleInlinedTextChild !== undefined) {
Expand All @@ -649,24 +661,23 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
// Node with (optional) props and some children.
lines.push(escapedKey + ':');
for (const [name, value] of Object.entries(ariaNode.props))
lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value));
lines.push(indent(depth + 1) + '- /' + name + ': ' + yamlEscapeValueIfNeeded(value));

const childIndent = indent + ' ';
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode);
for (const child of ariaNode.children) {
if (typeof child === 'string')
visitText(includeText(ariaNode, child) ? child : '', childIndent);
visitText(includeText(ariaNode, child) ? child : '', depth + 1);
else
visit(child, childIndent, renderCursorPointer && !inCursorPointer);
visit(child, depth + 1, renderCursorPointer && !inCursorPointer);
}
}
};

for (const nodeToRender of nodesToRender) {
if (typeof nodeToRender === 'string')
visitText(nodeToRender, '');
visitText(nodeToRender, 0);
else
visit(nodeToRender, '', !!options.renderCursorPointer);
visit(nodeToRender, 0, !!options.renderCursorPointer);
}
return lines.join('\n');
}
Expand Down
2 changes: 1 addition & 1 deletion packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export class InjectedScript {
return this.incrementalAriaSnapshot(node, options).full;
}

incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string }): { full: string, incremental?: string, iframeRefs: string[] } {
incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, depth?: number }): { full: string, incremental?: string, iframeRefs: string[] } {
if (node.nodeType !== Node.ELEMENT_NODE)
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
const ariaSnapshot = generateAriaTree(node as Element, options);
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4520,6 +4520,11 @@ export interface Page {
* @param options
*/
snapshotForAI(options?: {
/**
* When specified, limits the depth of the snapshot.
*/
depth?: number;

/**
* 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
Expand Down Expand Up @@ -14689,6 +14694,11 @@ export interface Locator {
* @param options
*/
snapshotForAI(options?: {
/**
* When specified, limits the depth of the snapshot.
*/
depth?: number;

/**
* 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
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,8 @@ export class Locator implements api.Locator {
await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options, timeout: this._frame._timeout(options) });
}

async snapshotForAI(options: TimeoutOptions = {}): Promise<{ full: string }> {
return await this._frame._page!._channel.snapshotForAI({ timeout: this._frame._timeout(options), selector: this._selector });
async snapshotForAI(options: TimeoutOptions & { depth?: number } = {}): Promise<{ full: string }> {
return await this._frame._page!._channel.snapshotForAI({ timeout: this._frame._timeout(options), selector: this._selector, depth: options.depth });
}

async _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean, errorMessage?: string }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/client/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,8 +851,8 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return result.pdf;
}

async snapshotForAI(options: TimeoutOptions & { track?: string } = {}): Promise<{ full: string, incremental?: string }> {
return await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track });
async snapshotForAI(options: TimeoutOptions & { track?: string, depth?: number } = {}): Promise<{ full: string, incremental?: string }> {
return await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track, depth: options.depth });
}

async _setDockTile(image: Buffer) {
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,7 @@ scheme.PageRequestsResult = tObject({
scheme.PageSnapshotForAIParams = tObject({
track: tOptional(tString),
selector: tOptional(tString),
depth: tOptional(tInt),
timeout: tFloat,
});
scheme.PageSnapshotForAIResult = tObject({
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright-core/src/server/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ export class Page extends SdkObject<PageEventMap> {
await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {})));
}

async snapshotForAI(progress: Progress, options: { track?: string, doNotRenderActive?: boolean, selector?: string } = {}): Promise<{ full: string, incremental?: string }> {
async snapshotForAI(progress: Progress, options: { track?: string, doNotRenderActive?: boolean, selector?: string, depth?: number } = {}): Promise<{ full: string, incremental?: string }> {
if (options.selector && options.track)
throw new Error('Cannot specify both selector and track options');

Expand Down Expand Up @@ -1047,7 +1047,7 @@ export class InitScript extends DisposableObject {
}
}

async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, options: { track?: string, doNotRenderActive?: boolean, info?: SelectorInfo } = {}): Promise<{ full: string[], incremental?: string[] }> {
async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, options: { track?: string, doNotRenderActive?: boolean, info?: SelectorInfo, depth?: number } = {}): Promise<{ full: string[], incremental?: string[] }> {
// Only await the topmost navigations, inner frames will be empty when racing.
const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => {
try {
Expand All @@ -1069,6 +1069,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, optio
track: options.track,
doNotRenderActive: options.doNotRenderActive,
info: options.info,
depth: options.depth,
}));
if (snapshotOrRetry === true)
return continuePolling;
Expand Down
6 changes: 4 additions & 2 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class Response {
private _includeSnapshot: 'none' | 'full' | 'incremental' = 'none';
private _includeSnapshotFileName: string | undefined;
private _includeSnapshotSelector: string | undefined;
private _includeSnapshotDepth: number | undefined;
private _isClose: boolean = false;

readonly toolName: string;
Expand Down Expand Up @@ -127,9 +128,10 @@ export class Response {
this._includeSnapshot = this._context.config.snapshot?.mode || 'incremental';
}

setIncludeFullSnapshot(includeSnapshotFileName?: string, selector?: string) {
setIncludeFullSnapshot(includeSnapshotFileName?: string, selector?: string, depth?: number) {
this._includeSnapshot = 'full';
this._includeSnapshotFileName = includeSnapshotFileName;
this._includeSnapshotDepth = depth;
this._includeSnapshotSelector = selector;
}

Expand Down Expand Up @@ -195,7 +197,7 @@ export class Response {
addSection('Ran Playwright code', this._code, 'js');

// Render tab titles upon changes or when more than one tab.
const tabSnapshot = this._context.currentTab() ? await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshotSelector, this._clientWorkspace) : undefined;
const tabSnapshot = this._context.currentTab() ? await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshotSelector, this._includeSnapshotDepth, this._clientWorkspace) : undefined;
const tabHeaders = await Promise.all(this._context.tabs().map(tab => tab.headerSnapshot()));
if (this._includeSnapshot !== 'none' || tabHeaders.some(header => header.changed)) {
if (tabHeaders.length !== 1)
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright-core/src/tools/backend/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ const snapshot = defineTool({
inputSchema: z.object({
filename: z.string().optional().describe('Save snapshot to markdown file instead of returning it in the response.'),
selector: z.string().optional().describe('Element selector of the root element to capture a partial snapshot instead of the whole page'),
depth: z.number().optional().describe('Limit the depth of the snapshot tree'),
}),
type: 'readOnly',
},

handle: async (context, params, response) => {
await context.ensureTab();
response.setIncludeFullSnapshot(params.filename, params.selector);
response.setIncludeFullSnapshot(params.filename, params.selector, params.depth);
},
});

Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/backend/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,11 @@ export class Tab extends EventEmitter<TabEventsInterface> {
this._requests.length = 0;
}

async captureSnapshot(selector: string | undefined, relativeTo: string | undefined): Promise<TabSnapshot> {
async captureSnapshot(selector: string | undefined, depth: number | undefined, relativeTo: string | undefined): Promise<TabSnapshot> {
await this._initializedPromise;
let tabSnapshot: TabSnapshot | undefined;
const modalStates = await this._raceAgainstModalStates(async () => {
const snapshot: { full: string, incremental?: string } = selector ? await this.page.locator(selector).snapshotForAI() : await this.page.snapshotForAI({ track: 'response' });
const snapshot: { full: string, incremental?: string } = selector ? await this.page.locator(selector).snapshotForAI({ depth }) : await this.page.snapshotForAI({ track: 'response', depth });
tabSnapshot = {
ariaSnapshot: snapshot.full,
ariaSnapshotDiff: this._needsFullSnapshot ? undefined : snapshot.incremental,
Expand Down
17 changes: 14 additions & 3 deletions packages/playwright-core/src/tools/cli-client/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ playwright-cli upload ./document.pdf
playwright-cli check e12
playwright-cli uncheck e12
playwright-cli snapshot
playwright-cli snapshot --filename=after-click.yaml
playwright-cli eval "document.title"
playwright-cli eval "el => el.textContent" e5
# get element id, class, or any attribute not visible in the snapshot
Expand Down Expand Up @@ -195,9 +194,21 @@ After each command, playwright-cli provides a snapshot of the current browser st
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
```

You can also take a snapshot on demand using `playwright-cli snapshot` command.
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.

```bash
# default - save to a file with timestamp-based name
playwright-cli snapshot

# save to file, use when snapshot is a part of the workflow result
playwright-cli snapshot --filename=after-click.yaml

# snapshot an element instead of the whole page
playwright-cli snapshot "#main"

If `--filename` is not provided, a new snapshot file is created with a timestamp. Default to automatic file naming, use `--filename=` when artifact is a part of the workflow result.
# limit snapshot depth for efficiency
playwright-cli snapshot --depth=4
```

## Targeting elements

Expand Down
3 changes: 2 additions & 1 deletion packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,10 @@ const snapshot = declareCommand({
}),
options: z.object({
filename: z.string().optional().describe('Save snapshot to markdown file instead of returning it in the response.'),
depth: numberArg.optional().describe('Limit snapshot depth, unlimited by default.'),
}),
toolName: 'browser_snapshot',
toolParams: ({ filename, element }) => ({ filename, selector: element }),
toolParams: ({ filename, element, depth }) => ({ filename, selector: element, depth }),
});

const evaluate = declareCommand({
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4520,6 +4520,11 @@ export interface Page {
* @param options
*/
snapshotForAI(options?: {
/**
* When specified, limits the depth of the snapshot.
*/
depth?: number;

/**
* 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
Expand Down Expand Up @@ -14689,6 +14694,11 @@ export interface Locator {
* @param options
*/
snapshotForAI(options?: {
/**
* When specified, limits the depth of the snapshot.
*/
depth?: number;

/**
* 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
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/src/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2613,11 +2613,13 @@ export type PageRequestsResult = {
export type PageSnapshotForAIParams = {
track?: string,
selector?: string,
depth?: number,
timeout: number,
};
export type PageSnapshotForAIOptions = {
track?: string,
selector?: string,
depth?: number,
};
export type PageSnapshotForAIResult = {
full: string,
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,7 @@ Page:
# When track is present, an incremental snapshot is returned when possible.
track: string?
selector: string?
depth: int?
timeout: float
returns:
full: string
Expand Down
17 changes: 17 additions & 0 deletions tests/mcp/cli-core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,20 @@ test('partial snapshot', async ({ cli, server }) => {
const { output: noMatchError } = await cli('snapshot', '#target');
expect(noMatchError).toContain(`Selector "#target" does not match any element`);
});

test('snapshot depth', async ({ cli, server }) => {
server.setContent('/', `<ul><li><button id=one>Submit</button></li><li><button id=two>Cancel</button></li></ul>`, 'text/html');
await cli('open', server.PREFIX);

const { snapshot: limitedSnapshot } = await cli('snapshot', '--depth=1');
expect(limitedSnapshot).toBe(`- list [ref=e2]:
- listitem [ref=e3]
- listitem [ref=e5]`);

const { snapshot: fullSnapshot } = await cli('snapshot', '--depth=100');
expect(fullSnapshot).toBe(`- list [ref=e2]:
- listitem [ref=e3]:
- button "Submit" [ref=e4]
- listitem [ref=e5]:
- button "Cancel" [ref=e6]`);
});
Loading
Loading