Skip to content

Commit 46f02da

Browse files
committed
chore(mcp): do not render page state when snapshot has not changed
Drive-by: return both full and incremental snapshot from `_snapshotForAI`.
1 parent 4a83d62 commit 46f02da

File tree

13 files changed

+92
-90
lines changed

13 files changed

+92
-90
lines changed

packages/injected/src/injectedScript.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -301,21 +301,23 @@ export class InjectedScript {
301301
}
302302

303303
ariaSnapshot(node: Node, options: AriaTreeOptions): string {
304-
return this.incrementalAriaSnapshot(node, options).snapshot;
304+
return this.incrementalAriaSnapshot(node, options).full;
305305
}
306306

307-
incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, incremental?: boolean }): { snapshot: string, iframeRefs: string[], isIncremental: boolean } {
307+
incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string }): { full: string, incremental?: string, iframeRefs: string[] } {
308308
if (node.nodeType !== Node.ELEMENT_NODE)
309309
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
310310
const ariaSnapshot = generateAriaTree(node as Element, options);
311-
let previous: AriaSnapshot | undefined;
312-
if (options.incremental)
313-
previous = options.track ? this._lastAriaSnapshotForTrack.get(options.track) : undefined;
314-
const snapshot = renderAriaTree(ariaSnapshot, options, previous);
315-
if (options.track)
311+
const full = renderAriaTree(ariaSnapshot, options);
312+
let incremental: string | undefined;
313+
if (options.track) {
314+
const previousSnapshot = this._lastAriaSnapshotForTrack.get(options.track);
315+
if (previousSnapshot)
316+
incremental = renderAriaTree(ariaSnapshot, options, previousSnapshot);
316317
this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot);
318+
}
317319
this._lastAriaSnapshotForQuery = ariaSnapshot;
318-
return { snapshot, iframeRefs: ariaSnapshot.iframeRefs, isIncremental: !!previous };
320+
return { full, incremental, iframeRefs: ariaSnapshot.iframeRefs };
319321
}
320322

321323
ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map<Element, string> } {

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

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

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 });
852-
return result.snapshot;
850+
async _snapshotForAI(options: TimeoutOptions & { track?: string } = {}): Promise<{ full: string, incremental?: string }> {
851+
return await this._channel.snapshotForAI({ timeout: this._timeoutSettings.timeout(options), track: options.track });
853852
}
854853
}
855854

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,11 +1466,11 @@ scheme.PageRequestsResult = tObject({
14661466
});
14671467
scheme.PageSnapshotForAIParams = tObject({
14681468
track: tOptional(tString),
1469-
mode: tOptional(tEnum(['full', 'incremental'])),
14701469
timeout: tFloat,
14711470
});
14721471
scheme.PageSnapshotForAIResult = tObject({
1473-
snapshot: tString,
1472+
full: tString,
1473+
incremental: tOptional(tString),
14741474
});
14751475
scheme.PageStartJSCoverageParams = tObject({
14761476
resetOnNavigation: tOptional(tBoolean),

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, params) };
355+
return 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: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -858,9 +858,9 @@ export class Page extends SdkObject {
858858
await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {})));
859859
}
860860

861-
async snapshotForAI(progress: Progress, options: { track?: string, mode?: 'full' | 'incremental' }): Promise<string> {
861+
async snapshotForAI(progress: Progress, options: { track?: string }): Promise<{ full: string, incremental?: string }> {
862862
const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), options);
863-
return snapshot.lines.join('\n');
863+
return { full: snapshot.full.join('\n'), incremental: snapshot.incremental?.join('\n') };
864864
}
865865
}
866866

@@ -1035,9 +1035,9 @@ class FrameThrottler {
10351035
}
10361036
}
10371037

1038-
async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, options: { track?: string, mode?: 'full' | 'incremental' }): Promise<{ lines: string[], isIncremental: boolean }> {
1038+
async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, options: { track?: string }): Promise<{ full: string[], incremental?: string[] }> {
10391039
// Only await the topmost navigations, inner frames will be empty when racing.
1040-
const { snapshot, iframeRefs, isIncremental } = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => {
1040+
const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => {
10411041
try {
10421042
const context = await progress.race(frame._utilityContext());
10431043
const injectedScript = await progress.race(context.injectedScript());
@@ -1046,7 +1046,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, optio
10461046
if (!node)
10471047
return true;
10481048
return injected.incrementalAriaSnapshot(node, { mode: 'ai', ...options });
1049-
}, { refPrefix: frame.seq ? 'f' + frame.seq : '', incremental: options.mode === 'incremental', track: options.track }));
1049+
}, { refPrefix: frame.seq ? 'f' + frame.seq : '', track: options.track }));
10501050
if (snapshotOrRetry === true)
10511051
return continuePolling;
10521052
return snapshotOrRetry;
@@ -1057,50 +1057,50 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, optio
10571057
}
10581058
});
10591059

1060-
const lines = snapshot.split('\n');
1061-
const result = [];
1062-
1063-
if (isIncremental) {
1064-
result.push(...lines);
1065-
for (const ref of iframeRefs) {
1066-
const childSnapshot = await snapshotFrameRefForAI(progress, frame, ref, options);
1067-
if (!childSnapshot.lines.length)
1068-
continue;
1069-
if (childSnapshot.isIncremental)
1070-
result.push(...childSnapshot.lines);
1071-
else
1072-
result.push('- <changed> iframe [ref=' + ref + ']:', ...childSnapshot.lines.map(l => ' ' + l));
1060+
const childSnapshotPromises = snapshot.iframeRefs.map(ref => snapshotFrameRefForAI(progress, frame, ref, options));
1061+
const childSnapshots = await Promise.all(childSnapshotPromises);
1062+
1063+
const full = [];
1064+
let incremental: string[] | undefined;
1065+
1066+
if (snapshot.incremental !== undefined) {
1067+
incremental = snapshot.incremental.split('\n');
1068+
for (let i = 0; i < snapshot.iframeRefs.length; i++) {
1069+
const childSnapshot = childSnapshots[i];
1070+
if (childSnapshot.incremental)
1071+
incremental.push(...childSnapshot.incremental);
1072+
else if (childSnapshot.full.length)
1073+
incremental.push('- <changed> iframe [ref=' + snapshot.iframeRefs[i] + ']:', ...childSnapshot.full.map(l => ' ' + l));
10731074
}
1074-
return { lines: result, isIncremental };
10751075
}
10761076

1077-
for (const line of lines) {
1077+
for (const line of snapshot.full.split('\n')) {
10781078
const match = line.match(/^(\s*)- iframe (?:\[active\] )?\[ref=([^\]]*)\]/);
10791079
if (!match) {
1080-
result.push(line);
1080+
full.push(line);
10811081
continue;
10821082
}
10831083

10841084
const leadingSpace = match[1];
10851085
const ref = match[2];
1086-
const childSnapshot = await snapshotFrameRefForAI(progress, frame, ref, options);
1087-
result.push(childSnapshot.lines.length ? line + ':' : line);
1088-
result.push(...childSnapshot.lines.map(l => leadingSpace + ' ' + l));
1086+
const childSnapshot = childSnapshots[snapshot.iframeRefs.indexOf(ref)] ?? { full: [] };
1087+
full.push(childSnapshot.full.length ? line + ':' : line);
1088+
full.push(...childSnapshot.full.map(l => leadingSpace + ' ' + l));
10891089
}
10901090

1091-
return { lines: result, isIncremental };
1091+
return { full, incremental };
10921092
}
10931093

1094-
async function snapshotFrameRefForAI(progress: Progress, parentFrame: frames.Frame, frameRef: string, options: { track?: string, mode?: 'full' | 'incremental' }): Promise<{ lines: string[], isIncremental: boolean }> {
1094+
async function snapshotFrameRefForAI(progress: Progress, parentFrame: frames.Frame, frameRef: string, options: { track?: string, mode?: 'full' | 'incremental' }): Promise<{ full: string[], incremental?: string[] }> {
10951095
const frameSelector = `aria-ref=${frameRef} >> internal:control=enter-frame`;
10961096
const frameBodySelector = `${frameSelector} >> body`;
10971097
const child = await progress.race(parentFrame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true }));
10981098
if (!child)
1099-
return { lines: [], isIncremental: false };
1099+
return { full: [] };
11001100
try {
11011101
return await snapshotFrameForAI(progress, child.frame, options);
11021102
} catch {
1103-
return { lines: [], isIncremental: false };
1103+
return { full: [] };
11041104
}
11051105
}
11061106

packages/playwright/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ class ArtifactsRecorder {
681681
try {
682682
// TODO: maybe capture snapshot when the error is created, so it's from the right page and right time
683683
await page._wrapApiCall(async () => {
684-
this._pageSnapshot = await page._snapshotForAI({ timeout: 5000 });
684+
this._pageSnapshot = (await page._snapshotForAI({ timeout: 5000 })).full;
685685
}, { internal: true });
686686
} catch {}
687687
}

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class Response {
8787
// All the async snapshotting post-action is happening here.
8888
// Everything below should race against modal states.
8989
if (this._includeSnapshot !== 'none' && this._context.currentTab())
90-
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshot);
90+
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
9191
for (const tab of this._context.tabs())
9292
await tab.updateTitle();
9393
}
@@ -134,7 +134,8 @@ ${this._code.join('\n')}
134134
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
135135
response.push('');
136136
} else if (this._tabSnapshot) {
137-
response.push(renderTabSnapshot(this._tabSnapshot, options));
137+
const includeSnapshot = options.omitSnapshot ? 'none' : this._includeSnapshot;
138+
response.push(renderTabSnapshot(this._tabSnapshot, includeSnapshot));
138139
response.push('');
139140
}
140141

@@ -166,7 +167,7 @@ ${this._code.join('\n')}
166167
}
167168
}
168169

169-
function renderTabSnapshot(tabSnapshot: TabSnapshot, options: { omitSnapshot?: boolean } = {}): string {
170+
function renderTabSnapshot(tabSnapshot: TabSnapshot, includeSnapshot: 'none' | 'full' | 'incremental'): string {
170171
const lines: string[] = [];
171172

172173
if (tabSnapshot.consoleMessages.length) {
@@ -187,14 +188,24 @@ function renderTabSnapshot(tabSnapshot: TabSnapshot, options: { omitSnapshot?: b
187188
lines.push('');
188189
}
189190

191+
if (includeSnapshot === 'incremental' && tabSnapshot.ariaSnapshotDiff === '') {
192+
// When incremental snapshot is present, but empty, do not render page state altogether.
193+
return lines.join('\n');
194+
}
195+
190196
lines.push(`### Page state`);
191197
lines.push(`- Page URL: ${tabSnapshot.url}`);
192198
lines.push(`- Page Title: ${tabSnapshot.title}`);
193-
lines.push(`- Page Snapshot:`);
194-
lines.push('```yaml');
195-
// TODO: perhaps not render page state when there are no changes?
196-
lines.push(options.omitSnapshot ? '<snapshot>' : (tabSnapshot.ariaSnapshot || '<no changes>'));
197-
lines.push('```');
199+
200+
if (includeSnapshot !== 'none') {
201+
lines.push(`- Page Snapshot:`);
202+
lines.push('```yaml');
203+
if (includeSnapshot === 'incremental' && tabSnapshot.ariaSnapshotDiff !== undefined)
204+
lines.push(tabSnapshot.ariaSnapshotDiff);
205+
else
206+
lines.push(tabSnapshot.ariaSnapshot);
207+
lines.push('```');
208+
}
198209

199210
return lines.join('\n');
200211
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type TabSnapshot = {
4040
url: string;
4141
title: string;
4242
ariaSnapshot: string;
43+
ariaSnapshotDiff?: string;
4344
modalStates: ModalState[];
4445
consoleMessages: ConsoleMessage[];
4546
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
@@ -217,14 +218,15 @@ export class Tab extends EventEmitter<TabEventsInterface> {
217218
return this._requests;
218219
}
219220

220-
async captureSnapshot(mode: 'full' | 'incremental'): Promise<TabSnapshot> {
221+
async captureSnapshot(): Promise<TabSnapshot> {
221222
let tabSnapshot: TabSnapshot | undefined;
222223
const modalStates = await this._raceAgainstModalStates(async () => {
223-
const snapshot = await this.page._snapshotForAI({ mode, track: 'response' });
224+
const snapshot = await this.page._snapshotForAI({ track: 'response' });
224225
tabSnapshot = {
225226
url: this.page.url(),
226227
title: await this.page.title(),
227-
ariaSnapshot: snapshot,
228+
ariaSnapshot: snapshot.full,
229+
ariaSnapshotDiff: snapshot.incremental,
228230
modalStates: [],
229231
consoleMessages: [],
230232
downloads: this._downloads,

packages/playwright/src/mcp/test/browserBackend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export async function runBrowserBackendAtEnd(context: playwright.BrowserContext,
6161
lines.push(
6262
`- Page Snapshot:`,
6363
'```yaml',
64-
await (page as Page)._snapshotForAI(),
64+
(await (page as Page)._snapshotForAI()).full,
6565
'```',
6666
);
6767
}

packages/protocol/src/channels.d.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2549,15 +2549,14 @@ export type PageRequestsResult = {
25492549
};
25502550
export type PageSnapshotForAIParams = {
25512551
track?: string,
2552-
mode?: 'full' | 'incremental',
25532552
timeout: number,
25542553
};
25552554
export type PageSnapshotForAIOptions = {
25562555
track?: string,
2557-
mode?: 'full' | 'incremental',
25582556
};
25592557
export type PageSnapshotForAIResult = {
2560-
snapshot: string,
2558+
full: string,
2559+
incremental?: string,
25612560
};
25622561
export type PageStartJSCoverageParams = {
25632562
resetOnNavigation?: boolean,

0 commit comments

Comments
 (0)