chore: follow up on tab snapshot capture (#739)

This commit is contained in:
Pavel Feldman 2025-07-22 17:43:42 -07:00 committed by GitHub
parent 601a74305c
commit 6320b08173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 80 additions and 101 deletions

View File

@ -45,6 +45,10 @@ export class Context {
return this._tabs; return this._tabs;
} }
currentTab(): Tab | undefined {
return this._currentTab;
}
currentTabOrDie(): Tab { currentTabOrDie(): Tab {
if (!this._currentTab) if (!this._currentTab)
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.'); throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');

View File

@ -23,7 +23,6 @@ export class Response {
private _images: { contentType: string, data: Buffer }[] = []; private _images: { contentType: string, data: Buffer }[] = [];
private _context: Context; private _context: Context;
private _includeSnapshot = false; private _includeSnapshot = false;
private _snapshot: string | undefined;
private _includeTabs = false; private _includeTabs = false;
constructor(context: Context) { constructor(context: Context) {
@ -54,10 +53,6 @@ export class Response {
return this._includeSnapshot; return this._includeSnapshot;
} }
addSnapshot(snapshot: string) {
this._snapshot = snapshot;
}
async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> { async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> {
const response: string[] = []; const response: string[] = [];
@ -82,8 +77,8 @@ ${this._code.join('\n')}
response.push(...(await this._context.listTabsMarkdown(this._includeTabs))); response.push(...(await this._context.listTabsMarkdown(this._includeTabs)));
// Add snapshot if provided. // Add snapshot if provided.
if (this._snapshot) if (this._includeSnapshot && this._context.currentTab())
response.push(this._snapshot, ''); response.push(await this._context.currentTabOrDie().captureSnapshot(), '');
// Main response part // Main response part
const content: (TextContent | ImageContent)[] = [ const content: (TextContent | ImageContent)[] = [

View File

@ -14,8 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import { EventEmitter } from 'events';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { logUnhandledError } from './log.js'; import { logUnhandledError } from './log.js';
import { ManualPromise } from './manualPromise.js'; import { ManualPromise } from './manualPromise.js';
@ -23,28 +23,31 @@ import { ModalState } from './tools/tool.js';
import { outputFile } from './config.js'; import { outputFile } from './config.js';
import type { Context } from './context.js'; import type { Context } from './context.js';
import type { Response } from './response.js';
type PageEx = playwright.Page & { type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>; _snapshotForAI: () => Promise<string>;
}; };
type PendingAction = { export const TabEvents = {
dialogShown: ManualPromise<void>; modalState: 'modalState'
}; };
export class Tab { export type TabEventsInterface = {
[TabEvents.modalState]: [modalState: ModalState];
};
export class Tab extends EventEmitter<TabEventsInterface> {
readonly context: Context; readonly context: Context;
readonly page: playwright.Page; readonly page: playwright.Page;
private _consoleMessages: ConsoleMessage[] = []; private _consoleMessages: ConsoleMessage[] = [];
private _recentConsoleMessages: ConsoleMessage[] = []; private _recentConsoleMessages: ConsoleMessage[] = [];
private _pendingAction: PendingAction | undefined;
private _requests: Map<playwright.Request, playwright.Response | null> = new Map(); private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _onPageClose: (tab: Tab) => void; private _onPageClose: (tab: Tab) => void;
private _modalStates: ModalState[] = []; private _modalStates: ModalState[] = [];
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
super();
this.context = context; this.context = context;
this.page = page; this.page = page;
this._onPageClose = onPageClose; this._onPageClose = onPageClose;
@ -74,6 +77,7 @@ export class Tab {
setModalState(modalState: ModalState) { setModalState(modalState: ModalState) {
this._modalStates.push(modalState); this._modalStates.push(modalState);
this.emit(TabEvents.modalState, modalState);
} }
clearModalState(modalState: ModalState) { clearModalState(modalState: ModalState) {
@ -97,7 +101,6 @@ export class Tab {
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`, description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog, dialog,
}); });
this._pendingAction?.dialogShown.resolve();
} }
private async _downloadStarted(download: playwright.Download) { private async _downloadStarted(download: playwright.Download) {
@ -196,7 +199,7 @@ export class Tab {
return result; return result;
} }
async captureSnapshot(options: { omitAriaSnapshot?: boolean } = {}): Promise<string> { async captureSnapshot(): Promise<string> {
const result: string[] = []; const result: string[] = [];
if (this.modalStates().length) { if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown()); result.push(...this.modalStatesMarkdown());
@ -205,19 +208,19 @@ export class Tab {
result.push(...this._takeRecentConsoleMarkdown()); result.push(...this._takeRecentConsoleMarkdown());
result.push(...this._listDownloadsMarkdown()); result.push(...this._listDownloadsMarkdown());
if (options.omitAriaSnapshot)
return result.join('\n');
const snapshot = await (this.page as PageEx)._snapshotForAI(); await this._raceAgainstModalStates(async () => {
result.push( const snapshot = await (this.page as PageEx)._snapshotForAI();
`### Page state`, result.push(
`- Page URL: ${this.page.url()}`, `### Page state`,
`- Page Title: ${await this.page.title()}`, `- Page URL: ${this.page.url()}`,
`- Page Snapshot:`, `- Page Title: ${await this.page.title()}`,
'```yaml', `- Page Snapshot:`,
snapshot, '```yaml',
'```', snapshot,
); '```',
);
});
return result.join('\n'); return result.join('\n');
} }
@ -225,42 +228,25 @@ export class Tab {
return this._modalStates.some(state => state.type === 'dialog'); return this._modalStates.some(state => state.type === 'dialog');
} }
private async _raceAgainstModalDialogs<R>(action: () => Promise<R>): Promise<R | undefined> { private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState | undefined> {
this._pendingAction = { if (this.modalStates().length)
dialogShown: new ManualPromise(), return this.modalStates()[0];
};
let result: R | undefined; const promise = new ManualPromise<ModalState>();
try { const listener = (modalState: ModalState) => promise.resolve(modalState);
await Promise.race([ this.once(TabEvents.modalState, listener);
action().then(r => result = r),
this._pendingAction.dialogShown, return await Promise.race([
]); action().then(() => {
} finally { this.off(TabEvents.modalState, listener);
this._pendingAction = undefined; return undefined;
} }),
return result; promise,
]);
} }
async run(callback: () => Promise<void>, response: Response) { async waitForCompletion(callback: () => Promise<void>) {
let snapshot: string | undefined; await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
await this._raceAgainstModalDialogs(async () => {
try {
if (response.includeSnapshot())
await waitForCompletion(this, callback);
else
await callback();
} finally {
snapshot = await this.captureSnapshot();
}
});
if (snapshot) {
response.addSnapshot(snapshot);
} else if (response.includeSnapshot()) {
// We are blocked on modal dialog.
response.addSnapshot(await this.captureSnapshot({ omitAriaSnapshot: true }));
}
} }
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> { async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {

View File

@ -52,9 +52,9 @@ const resize = defineTabTool({
response.addCode(`// Resize browser window to ${params.width}x${params.height}`); response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`); response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await tab.page.setViewportSize({ width: params.width, height: params.height }); await tab.page.setViewportSize({ width: params.width, height: params.height });
}, response); });
}, },
}); });

View File

@ -39,12 +39,12 @@ const handleDialog = defineTabTool({
throw new Error('No dialog visible'); throw new Error('No dialog visible');
tab.clearModalState(dialogState); tab.clearModalState(dialogState);
await tab.run(async () => { await tab.waitForCompletion(async () => {
if (params.accept) if (params.accept)
await dialogState.dialog.accept(params.promptText); await dialogState.dialog.accept(params.promptText);
else else
await dialogState.dialog.dismiss(); await dialogState.dialog.dismiss();
}, response); });
}, },
clearsModalState: 'dialog', clearsModalState: 'dialog',

View File

@ -49,11 +49,11 @@ const evaluate = defineTabTool({
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`); response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
} }
await tab.run(async () => { await tab.waitForCompletion(async () => {
const receiver = locator ?? tab.page as any; const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function); const result = await receiver._evaluateFunction(params.function);
response.addResult(JSON.stringify(result, null, 2) || 'undefined'); response.addResult(JSON.stringify(result, null, 2) || 'undefined');
}, response); });
}, },
}); });

View File

@ -40,10 +40,10 @@ const uploadFile = defineTabTool({
response.addCode(`// Select files for upload`); response.addCode(`// Select files for upload`);
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`); response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
await tab.run(async () => { tab.clearModalState(modalState);
await tab.waitForCompletion(async () => {
await modalState.fileChooser.setFiles(params.paths); await modalState.fileChooser.setFiles(params.paths);
tab.clearModalState(modalState); });
}, response);
}, },
clearsModalState: 'fileChooser', clearsModalState: 'fileChooser',
}); });

View File

@ -39,9 +39,9 @@ const pressKey = defineTabTool({
response.addCode(`// Press ${params.key}`); response.addCode(`// Press ${params.key}`);
response.addCode(`await page.keyboard.press('${params.key}');`); response.addCode(`await page.keyboard.press('${params.key}');`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await tab.page.keyboard.press(params.key); await tab.page.keyboard.press(params.key);
}, response); });
}, },
}); });
@ -66,7 +66,7 @@ const type = defineTabTool({
const locator = await tab.refLocator(params); const locator = await tab.refLocator(params);
await tab.run(async () => { await tab.waitForCompletion(async () => {
if (params.slowly) { if (params.slowly) {
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`); response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`); response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
@ -81,7 +81,7 @@ const type = defineTabTool({
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`); response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
await locator.press('Enter'); await locator.press('Enter');
} }
}, response); });
}, },
}); });

View File

@ -38,9 +38,9 @@ const mouseMove = defineTabTool({
response.addCode(`// Move mouse to (${params.x}, ${params.y})`); response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`); response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.x, params.y); await tab.page.mouse.move(params.x, params.y);
}, response); });
}, },
}); });
@ -65,11 +65,11 @@ const mouseClick = defineTabTool({
response.addCode(`await page.mouse.down();`); response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.up();`); response.addCode(`await page.mouse.up();`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.x, params.y); await tab.page.mouse.move(params.x, params.y);
await tab.page.mouse.down(); await tab.page.mouse.down();
await tab.page.mouse.up(); await tab.page.mouse.up();
}, response); });
}, },
}); });
@ -97,12 +97,12 @@ const mouseDrag = defineTabTool({
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`); response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
response.addCode(`await page.mouse.up();`); response.addCode(`await page.mouse.up();`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.startX, params.startY); await tab.page.mouse.move(params.startX, params.startY);
await tab.page.mouse.down(); await tab.page.mouse.down();
await tab.page.mouse.move(params.endX, params.endY); await tab.page.mouse.move(params.endX, params.endY);
await tab.page.mouse.up(); await tab.page.mouse.up();
}, response); });
}, },
}); });

View File

@ -34,9 +34,9 @@ const navigate = defineTool({
const tab = await context.ensureTab(); const tab = await context.ensureTab();
await tab.navigate(params.url); await tab.navigate(params.url);
response.setIncludeSnapshot();
response.addCode(`// Navigate to ${params.url}`); response.addCode(`// Navigate to ${params.url}`);
response.addCode(`await page.goto('${params.url}');`); response.addCode(`await page.goto('${params.url}');`);
response.addSnapshot(await tab.captureSnapshot());
}, },
}); });
@ -54,9 +54,9 @@ const goBack = defineTabTool({
response.setIncludeSnapshot(); response.setIncludeSnapshot();
await tab.page.goBack(); await tab.page.goBack();
response.setIncludeSnapshot();
response.addCode(`// Navigate back`); response.addCode(`// Navigate back`);
response.addCode(`await page.goBack();`); response.addCode(`await page.goBack();`);
response.addSnapshot(await tab.captureSnapshot());
}, },
}); });
@ -73,9 +73,9 @@ const goForward = defineTabTool({
response.setIncludeSnapshot(); response.setIncludeSnapshot();
await tab.page.goForward(); await tab.page.goForward();
response.setIncludeSnapshot();
response.addCode(`// Navigate forward`); response.addCode(`// Navigate forward`);
response.addCode(`await page.goForward();`); response.addCode(`await page.goForward();`);
response.addSnapshot(await tab.captureSnapshot());
}, },
}); });

View File

@ -31,8 +31,8 @@ const snapshot = defineTool({
}, },
handle: async (context, params, response) => { handle: async (context, params, response) => {
const tab = await context.ensureTab(); await context.ensureTab();
response.addSnapshot(await tab.captureSnapshot()); response.setIncludeSnapshot();
}, },
}); });
@ -71,12 +71,12 @@ const click = defineTabTool({
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`); response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
} }
await tab.run(async () => { await tab.waitForCompletion(async () => {
if (params.doubleClick) if (params.doubleClick)
await locator.dblclick({ button }); await locator.dblclick({ button });
else else
await locator.click({ button }); await locator.click({ button });
}, response); });
}, },
}); });
@ -103,9 +103,9 @@ const drag = defineTabTool({
{ ref: params.endRef, element: params.endElement }, { ref: params.endRef, element: params.endElement },
]); ]);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await startLocator.dragTo(endLocator); await startLocator.dragTo(endLocator);
}, response); });
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`); response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
}, },
@ -127,9 +127,9 @@ const hover = defineTabTool({
const locator = await tab.refLocator(params); const locator = await tab.refLocator(params);
response.addCode(`await page.${await generateLocator(locator)}.hover();`); response.addCode(`await page.${await generateLocator(locator)}.hover();`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await locator.hover(); await locator.hover();
}, response); });
}, },
}); });
@ -154,9 +154,9 @@ const selectOption = defineTabTool({
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`); response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`); response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
await tab.run(async () => { await tab.waitForCompletion(async () => {
await locator.selectOption(params.values); await locator.selectOption(params.values);
}, response); });
}, },
}); });

View File

@ -48,9 +48,8 @@ const selectTab = defineTool({
}, },
handle: async (context, params, response) => { handle: async (context, params, response) => {
const tab = await context.selectTab(params.index); await context.selectTab(params.index);
response.setIncludeSnapshot(); response.setIncludeSnapshot();
response.addSnapshot(await tab.captureSnapshot());
}, },
}); });
@ -71,9 +70,7 @@ const newTab = defineTool({
const tab = await context.newTab(); const tab = await context.newTab();
if (params.url) if (params.url)
await tab.navigate(params.url); await tab.navigate(params.url);
response.setIncludeSnapshot(); response.setIncludeSnapshot();
response.addSnapshot(await tab.captureSnapshot());
}, },
}); });
@ -92,10 +89,7 @@ const closeTab = defineTool({
handle: async (context, params, response) => { handle: async (context, params, response) => {
await context.closeTab(params.index); await context.closeTab(params.index);
response.setIncludeTabs(); response.setIncludeSnapshot();
response.addCode(`await myPage.close();`);
if (context.tabs().length)
response.addSnapshot(await context.currentTabOrDie().captureSnapshot());
}, },
}); });

View File

@ -58,7 +58,7 @@ const wait = defineTool({
} }
response.addResult(`Waited for ${params.text || params.textGone || params.time}`); response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
response.addSnapshot(await tab.captureSnapshot()); response.setIncludeSnapshot();
}, },
}); });