Skip to content

Commit 23b31b5

Browse files
committed
feat: incremental aria snapshot
1 parent ccb908a commit 23b31b5

File tree

11 files changed

+127
-44
lines changed

11 files changed

+127
-44
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -495,21 +495,31 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
495495
return results;
496496
}
497497

498-
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): string {
498+
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previous?: AriaSnapshot): string {
499499
const options = toInternalOptions(publicOptions);
500500
const lines: string[] = [];
501501
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
502502
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
503-
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string, renderCursorPointer: boolean) => {
504-
if (typeof ariaNode === 'string') {
505-
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
506-
return;
507-
const text = yamlEscapeValueIfNeeded(renderString(ariaNode));
508-
if (text)
509-
lines.push(indent + '- text: ' + text);
510-
return;
503+
504+
const previousByRef = new Map<string, AriaNode>();
505+
const visitPrevious = (ariaNode: AriaNode) => {
506+
if (ariaNode.ref)
507+
previousByRef.set(ariaNode.ref, ariaNode);
508+
for (const child of ariaNode.children) {
509+
if (typeof child !== 'string')
510+
visitPrevious(child);
511511
}
512+
};
513+
if (previous)
514+
visitPrevious(previous.root);
515+
516+
const visitText = (text: string, indent: string) => {
517+
const escaped = yamlEscapeValueIfNeeded(renderString(text));
518+
if (escaped)
519+
lines.push(indent + '- text: ' + escaped);
520+
};
512521

522+
const createKey = (ariaNode: AriaNode, renderCursorPointer: boolean): string => {
513523
let key = ariaNode.role;
514524
// Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
515525
if (ariaNode.name && ariaNode.name.length <= 900) {
@@ -538,41 +548,93 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
538548
if (ariaNode.selected === true)
539549
key += ` [selected]`;
540550

541-
let inCursorPointer = false;
542551
if (ariaNode.ref) {
543552
key += ` [ref=${ariaNode.ref}]`;
544-
if (renderCursorPointer && hasPointerCursor(ariaNode)) {
545-
inCursorPointer = true;
553+
if (renderCursorPointer && hasPointerCursor(ariaNode))
546554
key += ' [cursor=pointer]';
547-
}
548555
}
556+
return key;
557+
};
558+
559+
const arePropsEqual = (a: AriaNode, b: AriaNode): boolean => {
560+
const aKeys = Object.keys(a.props);
561+
const bKeys = Object.keys(b.props);
562+
return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]);
563+
};
564+
565+
const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean, previousNode: AriaNode | undefined): { unchanged: boolean } => {
566+
if (ariaNode.ref)
567+
previousNode = previousByRef.get(ariaNode.ref);
568+
const linesBefore = lines.length;
569+
570+
const key = createKey(ariaNode, renderCursorPointer);
571+
let unchanged = !!previousNode && key === createKey(previousNode, renderCursorPointer) && arePropsEqual(ariaNode, previousNode);
549572

550573
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);
551574
const hasProps = !!Object.keys(ariaNode.props).length;
575+
const inCursorPointer = renderCursorPointer && !!ariaNode.ref && hasPointerCursor(ariaNode);
576+
552577
if (!ariaNode.children.length && !hasProps) {
578+
// Leaf node without children.
553579
lines.push(escapedKey);
554580
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !hasProps) {
555-
const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null;
581+
// Leaf node with only some text inside.
582+
const shouldInclude = includeText(ariaNode, ariaNode.children[0]);
583+
const text = shouldInclude ? renderString(ariaNode.children[0]) : null;
556584
if (text)
557585
lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(text));
558586
else
559587
lines.push(escapedKey);
588+
589+
// Node is unchanged only when previous node also had the same single text child.
590+
unchanged = unchanged && !!previousNode &&
591+
previousNode.children.length === 1 && typeof previousNode.children[0] === 'string' &&
592+
!Object.keys(previousNode.props).length && ariaNode.children[0] === previousNode.children[0];
560593
} else {
594+
// Node with (optional) props and some children.
561595
lines.push(escapedKey + ':');
562596
for (const [name, value] of Object.entries(ariaNode.props))
563597
lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value));
564-
for (const child of ariaNode.children || [])
565-
visit(child, ariaNode, indent + ' ', renderCursorPointer && !inCursorPointer);
598+
599+
// All children must be the same.
600+
unchanged = unchanged && previousNode?.children.length === ariaNode.children.length;
601+
602+
const childIndent = indent + ' ';
603+
for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) {
604+
const child = ariaNode.children[childIndex];
605+
if (typeof child === 'string') {
606+
const shouldInclude = includeText(ariaNode, child);
607+
if (shouldInclude)
608+
visitText(child, childIndent);
609+
unchanged = unchanged && previousNode?.children[childIndex] === child && shouldInclude === includeText(previousNode, child);
610+
} else {
611+
const previousChild = previousNode?.children[childIndex];
612+
const childResult = visit(child, childIndent, renderCursorPointer && !inCursorPointer, typeof previousChild !== 'string' ? previousChild : undefined);
613+
unchanged = unchanged && childResult.unchanged;
614+
}
615+
}
566616
}
617+
618+
if (unchanged && ariaNode.ref) {
619+
// Replace the whole subtree with a single reference.
620+
lines.splice(linesBefore);
621+
lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`);
622+
}
623+
624+
return { unchanged };
567625
};
568626

569627
const ariaNode = ariaSnapshot.root;
570628
if (ariaNode.role === 'fragment') {
571629
// Render fragment.
572-
for (const child of ariaNode.children || [])
573-
visit(child, ariaNode, '', !!options.renderCursorPointer);
630+
for (const child of ariaNode.children || []) {
631+
if (typeof child === 'string')
632+
visitText(child, '');
633+
else
634+
visit(child, '', !!options.renderCursorPointer, undefined);
635+
}
574636
} else {
575-
visit(ariaNode, null, '', !!options.renderCursorPointer);
637+
visit(ariaNode, '', !!options.renderCursorPointer, undefined);
576638
}
577639
return lines.join('\n');
578640
}

packages/injected/src/injectedScript.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export class InjectedScript {
9292
readonly window: Window & typeof globalThis;
9393
readonly document: Document;
9494
readonly consoleApi: ConsoleAPI;
95-
private _lastAriaSnapshot: AriaSnapshot | undefined;
95+
private _lastAriaSnapshotForTrack = new Map<string, AriaSnapshot>();
96+
private _lastAriaSnapshotForQuery: AriaSnapshot | undefined;
9697

9798
// Recorder must use any external dependencies through InjectedScript.
9899
// Otherwise it will end up with a copy of all modules it uses, and any
@@ -299,11 +300,18 @@ export class InjectedScript {
299300
return new Set<Element>(result.map(r => r.element));
300301
}
301302

302-
ariaSnapshot(node: Node, options: AriaTreeOptions): string {
303+
ariaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, incremental?: boolean }): string {
303304
if (node.nodeType !== Node.ELEMENT_NODE)
304305
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
305-
this._lastAriaSnapshot = generateAriaTree(node as Element, options);
306-
return renderAriaTree(this._lastAriaSnapshot, options);
306+
const ariaSnapshot = generateAriaTree(node as Element, options);
307+
let previous: AriaSnapshot | undefined;
308+
if (options.incremental)
309+
previous = options.track ? this._lastAriaSnapshotForTrack.get(options.track) : this._lastAriaSnapshotForQuery;
310+
const result = renderAriaTree(ariaSnapshot, options, previous);
311+
if (options.track)
312+
this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot);
313+
this._lastAriaSnapshotForQuery = ariaSnapshot;
314+
return result;
307315
}
308316

309317
ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map<Element, string> } {
@@ -692,7 +700,7 @@ export class InjectedScript {
692700

693701
_createAriaRefEngine() {
694702
const queryAll = (root: SelectorRoot, selector: string): Element[] => {
695-
const result = this._lastAriaSnapshot?.elements?.get(selector);
703+
const result = this._lastAriaSnapshotForQuery?.elements?.get(selector);
696704
return result && result.isConnected ? [result] : [];
697705
};
698706
return { queryAll };

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -847,8 +847,8 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
847847
return result.pdf;
848848
}
849849

850-
async _snapshotForAI(options: TimeoutOptions = {}): Promise<string> {
851-
const result = await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options) });
850+
async _snapshotForAI(options: TimeoutOptions & { track?: string, mode?: 'full' | 'incremental' } = {}): Promise<string> {
851+
const result = await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track, mode: options.mode });
852852
return result.snapshot;
853853
}
854854
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,8 @@ scheme.PageRequestsResult = tObject({
14651465
requests: tArray(tChannel(['Request'])),
14661466
});
14671467
scheme.PageSnapshotForAIParams = tObject({
1468+
track: tOptional(tString),
1469+
mode: tOptional(tEnum(['full', 'incremental'])),
14681470
timeout: tFloat,
14691471
});
14701472
scheme.PageSnapshotForAIResult = tObject({

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
352352
}
353353

354354
async snapshotForAI(params: channels.PageSnapshotForAIParams, progress: Progress): Promise<channels.PageSnapshotForAIResult> {
355-
return { snapshot: await this._page.snapshotForAI(progress) };
355+
return { snapshot: await this._page.snapshotForAI(progress, params) };
356356
}
357357

358358
async bringToFront(params: channels.PageBringToFrontParams, progress: Progress): Promise<void> {

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -859,9 +859,9 @@ export class Page extends SdkObject {
859859
await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {})));
860860
}
861861

862-
async snapshotForAI(progress: Progress): Promise<string> {
862+
async snapshotForAI(progress: Progress, options: { track?: string, mode?: 'full' | 'incremental' }): Promise<string> {
863863
this.lastSnapshotFrameIds = [];
864-
const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds);
864+
const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds, options);
865865
return snapshot.join('\n');
866866
}
867867
}
@@ -1037,18 +1037,18 @@ class FrameThrottler {
10371037
}
10381038
}
10391039

1040-
async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise<string[]> {
1040+
async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[], options: { track?: string, mode?: 'full' | 'incremental' }): Promise<string[]> {
10411041
// Only await the topmost navigations, inner frames will be empty when racing.
10421042
const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => {
10431043
try {
10441044
const context = await progress.race(frame._utilityContext());
10451045
const injectedScript = await progress.race(context.injectedScript());
1046-
const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, refPrefix) => {
1046+
const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, options) => {
10471047
const node = injected.document.body;
10481048
if (!node)
10491049
return true;
1050-
return injected.ariaSnapshot(node, { mode: 'ai', refPrefix });
1051-
}, frameOrdinal ? 'f' + frameOrdinal : ''));
1050+
return injected.ariaSnapshot(node, { mode: 'ai', ...options });
1051+
}, { refPrefix: frameOrdinal ? 'f' + frameOrdinal : '', incremental: options.mode === 'incremental', track: options.track }));
10521052
if (snapshotOrRetry === true)
10531053
return continuePolling;
10541054
return snapshotOrRetry;
@@ -1080,7 +1080,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame
10801080
const frameOrdinal = frameIds.length + 1;
10811081
frameIds.push(child.frame._id);
10821082
try {
1083-
const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds);
1083+
const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds, options);
10841084
result.push(line + ':', ...childSnapshot.map(l => leadingSpace + ' ' + l));
10851085
} catch {
10861086
result.push(line);

packages/playwright/src/mcp/browser/response.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class Response {
2828
private _code: string[] = [];
2929
private _images: { contentType: string, data: Buffer }[] = [];
3030
private _context: Context;
31-
private _includeSnapshot = false;
31+
private _includeSnapshot: 'none' | 'full' | 'incremental' = 'none';
3232
private _includeTabs = false;
3333
private _tabSnapshot: TabSnapshot | undefined;
3434

@@ -75,8 +75,8 @@ export class Response {
7575
return this._images;
7676
}
7777

78-
setIncludeSnapshot() {
79-
this._includeSnapshot = true;
78+
setIncludeSnapshot(full?: 'full') {
79+
this._includeSnapshot = full ?? 'incremental';
8080
}
8181

8282
setIncludeTabs() {
@@ -86,8 +86,8 @@ export class Response {
8686
async finish() {
8787
// All the async snapshotting post-action is happening here.
8888
// Everything below should race against modal states.
89-
if (this._includeSnapshot && this._context.currentTab())
90-
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
89+
if (this._includeSnapshot !== 'none' && this._context.currentTab())
90+
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshot);
9191
for (const tab of this._context.tabs())
9292
await tab.updateTitle();
9393
}
@@ -126,7 +126,7 @@ ${this._code.join('\n')}
126126
}
127127

128128
// List browser tabs.
129-
if (this._includeSnapshot || this._includeTabs)
129+
if (this._includeSnapshot !== 'none' || this._includeTabs)
130130
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
131131

132132
// Add snapshot if provided.

packages/playwright/src/mcp/browser/tab.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
6262
this.context = context;
6363
this.page = page as Page;
6464
this._onPageClose = onPageClose;
65+
page.on('console', console.error);
6566
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
6667
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
6768
page.on('request', request => this._requests.add(request));
@@ -217,10 +218,10 @@ export class Tab extends EventEmitter<TabEventsInterface> {
217218
return this._requests;
218219
}
219220

220-
async captureSnapshot(): Promise<TabSnapshot> {
221+
async captureSnapshot(mode: 'full' | 'incremental'): Promise<TabSnapshot> {
221222
let tabSnapshot: TabSnapshot | undefined;
222223
const modalStates = await this._raceAgainstModalStates(async () => {
223-
const snapshot = await this.page._snapshotForAI();
224+
const snapshot = await this.page._snapshotForAI({ mode, track: 'response' });
224225
tabSnapshot = {
225226
url: this.page.url(),
226227
title: await this.page.title(),

packages/playwright/src/mcp/browser/tools/snapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const snapshot = defineTool({
3030

3131
handle: async (context, params, response) => {
3232
await context.ensureTab();
33-
response.setIncludeSnapshot();
33+
response.setIncludeSnapshot('full');
3434
},
3535
});
3636

packages/protocol/src/channels.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2548,10 +2548,13 @@ export type PageRequestsResult = {
25482548
requests: RequestChannel[],
25492549
};
25502550
export type PageSnapshotForAIParams = {
2551+
track?: string,
2552+
mode?: 'full' | 'incremental',
25512553
timeout: number,
25522554
};
25532555
export type PageSnapshotForAIOptions = {
2554-
2556+
track?: string,
2557+
mode?: 'full' | 'incremental',
25552558
};
25562559
export type PageSnapshotForAIResult = {
25572560
snapshot: string,

0 commit comments

Comments
 (0)