chore: introduce response type (#738)

This commit is contained in:
Pavel Feldman 2025-07-22 16:36:21 -07:00 committed by GitHub
parent c2b98dc70b
commit 601a74305c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 443 additions and 526 deletions

View File

@ -19,6 +19,7 @@
"build": "tsc", "build": "tsc",
"build:extension": "tsc --project extension", "build:extension": "tsc --project extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit", "lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"update-readme": "node utils/update-readme.js", "update-readme": "node utils/update-readme.js",
"watch": "tsc --watch", "watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension", "watch:extension": "tsc --watch --project extension",

View File

@ -19,6 +19,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context.js'; import { Context } from './context.js';
import { Response } from './response.js';
import { allTools } from './tools.js'; import { allTools } from './tools.js';
import { packageJSON } from './package.js'; import { packageJSON } from './package.js';
@ -61,7 +62,9 @@ export function createConnection(config: FullConfig, browserContextFactory: Brow
return errorResult(`Tool "${request.params.name}" not found`); return errorResult(`Tool "${request.params.name}" not found`);
try { try {
return await context.run(tool, request.params.arguments); const response = new Response(context);
await tool.handle(context, tool.schema.inputSchema.parse(request.params.arguments || {}), response);
return await response.serialize();
} catch (error) { } catch (error) {
return errorResult(String(error)); return errorResult(String(error));
} }

View File

@ -59,8 +59,12 @@ export class Context {
} }
async selectTab(index: number) { async selectTab(index: number) {
this._currentTab = this._tabs[index]; const tab = this._tabs[index];
await this._currentTab.page.bringToFront(); if (!tab)
throw new Error(`Tab ${index} not found`);
await tab.page.bringToFront();
this._currentTab = tab;
return tab;
} }
async ensureTab(): Promise<Tab> { async ensureTab(): Promise<Tab> {
@ -70,9 +74,18 @@ export class Context {
return this._currentTab!; return this._currentTab!;
} }
async listTabsMarkdown(): Promise<string[]> { async listTabsMarkdown(force: boolean = false): Promise<string[]> {
if (!this._tabs.length) if (this._tabs.length === 1 && !force)
return ['### No tabs open']; return [];
if (!this._tabs.length) {
return [
'### No open tabs',
'Use the "browser_navigate" tool to navigate to a page first.',
'',
];
}
const lines: string[] = ['### Open tabs']; const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) { for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i]; const tab = this._tabs[i];
@ -81,62 +94,17 @@ export class Context {
const current = tab === this._currentTab ? ' (current)' : ''; const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i}:${current} [${title}] (${url})`); lines.push(`- ${i}:${current} [${title}] (${url})`);
} }
lines.push('');
return lines; return lines;
} }
async closeTab(index: number | undefined) { async closeTab(index: number | undefined): Promise<string> {
const tab = index === undefined ? this._currentTab : this._tabs[index]; const tab = index === undefined ? this._currentTab : this._tabs[index];
await tab?.page.close(); if (!tab)
return await this.listTabsMarkdown(); throw new Error(`Tab ${index} not found`);
} const url = tab.page.url();
await tab.page.close();
async run(tool: Tool, params: Record<string, unknown> | undefined) { return url;
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
if (resultOverride)
return resultOverride;
const tab = this.currentTabOrDie();
const { actionResult, snapshot } = await tab.run(action || (() => Promise.resolve()), { waitForNetwork, captureSnapshot });
const result: string[] = [];
result.push(`### Ran Playwright code
\`\`\`js
${code.join('\n')}
\`\`\``);
if (tab.modalStates().length) {
result.push('', ...tab.modalStatesMarkdown());
return {
content: [{
type: 'text',
text: result.join('\n'),
}],
};
}
result.push(...tab.takeRecentConsoleMarkdown());
result.push(...tab.listDownloadsMarkdown());
if (snapshot) {
if (this.tabs().length > 1)
result.push('', ...(await this.listTabsMarkdown()));
result.push('', snapshot);
}
const content = actionResult?.content ?? [];
return {
content: [
...content,
{
type: 'text',
text: result.join('\n'),
}
],
};
} }
private _onPageCreated(page: playwright.Page) { private _onPageCreated(page: playwright.Page) {

101
src/response.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Context } from './context.js';
export class Response {
private _result: string[] = [];
private _code: string[] = [];
private _images: { contentType: string, data: Buffer }[] = [];
private _context: Context;
private _includeSnapshot = false;
private _snapshot: string | undefined;
private _includeTabs = false;
constructor(context: Context) {
this._context = context;
}
addResult(result: string) {
this._result.push(result);
}
addCode(code: string) {
this._code.push(code);
}
addImage(image: { contentType: string, data: Buffer }) {
this._images.push(image);
}
setIncludeSnapshot() {
this._includeSnapshot = true;
}
setIncludeTabs() {
this._includeTabs = true;
}
includeSnapshot() {
return this._includeSnapshot;
}
addSnapshot(snapshot: string) {
this._snapshot = snapshot;
}
async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> {
const response: string[] = [];
// Start with command result.
if (this._result.length) {
response.push('### Result');
response.push(this._result.join('\n'));
response.push('');
}
// Add code if it exists.
if (this._code.length) {
response.push(`### Ran Playwright code
\`\`\`js
${this._code.join('\n')}
\`\`\``);
response.push('');
}
// List browser tabs.
if (this._includeSnapshot || this._includeTabs)
response.push(...(await this._context.listTabsMarkdown(this._includeTabs)));
// Add snapshot if provided.
if (this._snapshot)
response.push(this._snapshot, '');
// Main response part
const content: (TextContent | ImageContent)[] = [
{ type: 'text', text: response.join('\n') },
];
// Image attachments.
if (this._context.config.imageResponses !== 'omit') {
for (const image of this._images)
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
}
return { content };
}
}

View File

@ -23,7 +23,7 @@ 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 { ToolActionResult } from './tools/tool.js'; import type { Response } from './response.js';
type PageEx = playwright.Page & { type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>; _snapshotForAI: () => Promise<string>;
@ -85,7 +85,7 @@ export class Tab {
if (this._modalStates.length === 0) if (this._modalStates.length === 0)
result.push('- There is no modal state present'); result.push('- There is no modal state present');
for (const state of this._modalStates) { for (const state of this._modalStates) {
const tool = this.context.tools.find(tool => tool.clearsModalState === state.type); const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`); result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
} }
return result; return result;
@ -151,10 +151,13 @@ export class Tab {
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([ const download = await Promise.race([
downloadEvent, downloadEvent,
new Promise(resolve => setTimeout(resolve, 1000)), new Promise(resolve => setTimeout(resolve, 3000)),
]); ]);
if (!download) if (!download)
throw e; throw e;
// Make sure other "download" listeners are notified first.
await new Promise(resolve => setTimeout(resolve, 500));
return;
} }
// Cap load event to 5 seconds, the page is operational at this point. // Cap load event to 5 seconds, the page is operational at this point.
@ -169,40 +172,53 @@ export class Tab {
return this._requests; return this._requests;
} }
takeRecentConsoleMarkdown(): string[] { private _takeRecentConsoleMarkdown(): string[] {
if (!this._recentConsoleMessages.length) if (!this._recentConsoleMessages.length)
return []; return [];
const result = this._recentConsoleMessages.map(message => { const result = this._recentConsoleMessages.map(message => {
return `- ${trim(message.toString(), 100)}`; return `- ${trim(message.toString(), 100)}`;
}); });
return ['', `### New console messages`, ...result]; return [`### New console messages`, ...result, ''];
} }
listDownloadsMarkdown(): string[] { private _listDownloadsMarkdown(): string[] {
if (!this._downloads.length) if (!this._downloads.length)
return []; return [];
const result: string[] = ['', '### Downloads']; const result: string[] = ['### Downloads'];
for (const entry of this._downloads) { for (const entry of this._downloads) {
if (entry.finished) if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`); result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`); result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
} }
result.push('');
return result; return result;
} }
async captureSnapshot(): Promise<string> { async captureSnapshot(options: { omitAriaSnapshot?: boolean } = {}): Promise<string> {
const result: string[] = [];
if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown());
return result.join('\n');
}
result.push(...this._takeRecentConsoleMarkdown());
result.push(...this._listDownloadsMarkdown());
if (options.omitAriaSnapshot)
return result.join('\n');
const snapshot = await (this.page as PageEx)._snapshotForAI(); const snapshot = await (this.page as PageEx)._snapshotForAI();
return [ result.push(
`### Page state`, `### Page state`,
`- Page URL: ${this.page.url()}`, `- Page URL: ${this.page.url()}`,
`- Page Title: ${await this.page.title()}`, `- Page Title: ${await this.page.title()}`,
`- Page Snapshot:`, `- Page Snapshot:`,
'```yaml', '```yaml',
snapshot, snapshot,
'```', '```',
].join('\n'); );
return result.join('\n');
} }
private _javaScriptBlocked(): boolean { private _javaScriptBlocked(): boolean {
@ -226,20 +242,25 @@ export class Tab {
return result; return result;
} }
async run(callback: () => Promise<ToolActionResult>, options: { waitForNetwork?: boolean, captureSnapshot?: boolean }): Promise<{ actionResult: ToolActionResult | undefined, snapshot: string | undefined }> { async run(callback: () => Promise<void>, response: Response) {
let snapshot: string | undefined; let snapshot: string | undefined;
const actionResult = await this._raceAgainstModalDialogs(async () => { await this._raceAgainstModalDialogs(async () => {
try { try {
if (options.waitForNetwork) if (response.includeSnapshot())
return await waitForCompletion(this, async () => callback?.()) ?? undefined; await waitForCompletion(this, callback);
else else
return await callback?.() ?? undefined; await callback();
} finally { } finally {
if (options.captureSnapshot && !this._javaScriptBlocked()) snapshot = await this.captureSnapshot();
snapshot = await this.captureSnapshot();
} }
}); });
return { actionResult, snapshot };
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> {
@ -247,7 +268,7 @@ export class Tab {
} }
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> { async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
const snapshot = await this.captureSnapshot(); const snapshot = await (this.page as PageEx)._snapshotForAI();
return params.map(param => { return params.map(param => {
if (!snapshot.includes(`[ref=${param.ref}]`)) if (!snapshot.includes(`[ref=${param.ref}]`))
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`); throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);

View File

@ -28,13 +28,10 @@ const close = defineTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async context => { handle: async (context, params, response) => {
await context.close(); await context.close();
return { response.setIncludeTabs();
code: [`await page.close()`], response.addCode(`await page.close()`);
captureSnapshot: false,
waitForNetwork: false,
};
}, },
}); });
@ -51,22 +48,13 @@ const resize = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const code = [ response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
`// Resize browser window to ${params.width}x${params.height}`, response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
];
const action = async () => { await tab.run(async () => {
await tab.page.setViewportSize({ width: params.width, height: params.height }); await tab.page.setViewportSize({ width: params.width, height: params.height });
}; }, response);
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
}, },
}); });

View File

@ -26,19 +26,8 @@ const console = defineTabTool({
inputSchema: z.object({}), inputSchema: z.object({}),
type: 'readOnly', type: 'readOnly',
}, },
handle: async tab => { handle: async (tab, params, response) => {
const messages = tab.consoleMessages(); tab.consoleMessages().map(message => response.addResult(message.toString()));
const log = messages.map(message => message.toString()).join('\n');
return {
code: [`// <internal code to get console messages>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
}, },
}); });

View File

@ -31,27 +31,20 @@ const handleDialog = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const dialogState = tab.modalStates().find(state => state.type === 'dialog'); const dialogState = tab.modalStates().find(state => state.type === 'dialog');
if (!dialogState) if (!dialogState)
throw new Error('No dialog visible'); throw new Error('No dialog visible');
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();
tab.clearModalState(dialogState); tab.clearModalState(dialogState);
await tab.run(async () => {
const code = [ if (params.accept)
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`, await dialogState.dialog.accept(params.promptText);
]; else
await dialogState.dialog.dismiss();
return { }, response);
code,
captureSnapshot: true,
waitForNetwork: false,
};
}, },
clearsModalState: 'dialog', clearsModalState: 'dialog',

View File

@ -38,29 +38,22 @@ const evaluate = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const code: string[] = []; response.setIncludeSnapshot();
let locator: playwright.Locator | undefined; let locator: playwright.Locator | undefined;
if (params.ref && params.element) { if (params.ref && params.element) {
locator = await tab.refLocator({ ref: params.ref, element: params.element }); locator = await tab.refLocator({ ref: params.ref, element: params.element });
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`); response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
} else { } else {
code.push(`await page.evaluate(${javascript.quote(params.function)});`); response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
} }
return { await tab.run(async () => {
code, const receiver = locator ?? tab.page as any;
action: async () => { const result = await receiver._evaluateFunction(params.function);
const receiver = locator ?? tab.page as any; response.addResult(JSON.stringify(result, null, 2) || 'undefined');
const result = await receiver._evaluateFunction(params.function); }, response);
return {
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
};
},
captureSnapshot: false,
waitForNetwork: false,
};
}, },
}); });

View File

@ -30,26 +30,20 @@ const uploadFile = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const modalState = tab.modalStates().find(state => state.type === 'fileChooser'); const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
if (!modalState) if (!modalState)
throw new Error('No file chooser visible'); throw new Error('No file chooser visible');
const code = [ response.addCode(`// Select files for upload`);
`// <internal code to chose files ${params.paths.join(', ')}`, response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
];
const action = async () => { await tab.run(async () => {
await modalState.fileChooser.setFiles(params.paths); await modalState.fileChooser.setFiles(params.paths);
tab.clearModalState(modalState); tab.clearModalState(modalState);
}; }, response);
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true,
};
}, },
clearsModalState: 'fileChooser', clearsModalState: 'fileChooser',
}); });

View File

@ -31,7 +31,7 @@ const install = defineTool({
type: 'destructive', type: 'destructive',
}, },
handle: async context => { handle: async (context, params, response) => {
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome'; const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
const cliUrl = import.meta.resolve('playwright/package.json'); const cliUrl = import.meta.resolve('playwright/package.json');
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js'); const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
@ -49,11 +49,7 @@ const install = defineTool({
reject(new Error(`Failed to install browser: ${output.join('')}`)); reject(new Error(`Failed to install browser: ${output.join('')}`));
}); });
}); });
return { response.setIncludeTabs();
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
}, },
}); });

View File

@ -34,20 +34,14 @@ const pressKey = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const code = [ response.setIncludeSnapshot();
`// Press ${params.key}`, response.addCode(`// Press ${params.key}`);
`await page.keyboard.press('${params.key}');`, response.addCode(`await page.keyboard.press('${params.key}');`);
];
const action = () => tab.page.keyboard.press(params.key); await tab.run(async () => {
await tab.page.keyboard.press(params.key);
return { }, response);
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
}, },
}); });
@ -67,34 +61,27 @@ const type = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params); const locator = await tab.refLocator(params);
const code: string[] = []; await tab.run(async () => {
const steps: (() => Promise<void>)[] = []; if (params.slowly) {
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
await locator.pressSequentially(params.text);
} else {
response.addCode(`// Fill "${params.text}" into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
await locator.fill(params.text);
}
if (params.slowly) { if (params.submit) {
code.push(`// Press "${params.text}" sequentially into "${params.element}"`); response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`); await locator.press('Enter');
steps.push(() => locator.pressSequentially(params.text)); }
} else { }, response);
code.push(`// Fill "${params.text}" into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
steps.push(() => locator.fill(params.text));
}
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
}
return {
code,
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
captureSnapshot: true,
waitForNetwork: true,
};
}, },
}); });

View File

@ -34,18 +34,13 @@ const mouseMove = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const code = [ response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
`// Move mouse to (${params.x}, ${params.y})`, response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
`await page.mouse.move(${params.x}, ${params.y});`,
]; await tab.run(async () => {
const action = () => tab.page.mouse.move(params.x, params.y); await tab.page.mouse.move(params.x, params.y);
return { }, response);
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
}, },
}); });
@ -62,24 +57,19 @@ const mouseClick = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const code = [ response.setIncludeSnapshot();
`// Click mouse at coordinates (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`, response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
`await page.mouse.down();`, response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
`await page.mouse.up();`, response.addCode(`await page.mouse.down();`);
]; response.addCode(`await page.mouse.up();`);
const action = async () => {
await tab.run(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);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
}, },
}); });
@ -98,28 +88,21 @@ const mouseDrag = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const code = [ response.setIncludeSnapshot();
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
`await page.mouse.move(${params.startX}, ${params.startY});`,
`await page.mouse.down();`,
`await page.mouse.move(${params.endX}, ${params.endY});`,
`await page.mouse.up();`,
];
const action = async () => { response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
response.addCode(`await page.mouse.up();`);
await tab.run(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);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
}, },
}); });

View File

@ -30,20 +30,13 @@ const navigate = defineTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (context, params) => { handle: async (context, params, response) => {
const tab = await context.ensureTab(); const tab = await context.ensureTab();
await tab.navigate(params.url); await tab.navigate(params.url);
const code = [ response.addCode(`// Navigate to ${params.url}`);
`// Navigate to ${params.url}`, response.addCode(`await page.goto('${params.url}');`);
`await page.goto('${params.url}');`, response.addSnapshot(await tab.captureSnapshot());
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
}, },
}); });
@ -57,18 +50,13 @@ const goBack = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async tab => { handle: async (tab, params, response) => {
await tab.page.goBack(); response.setIncludeSnapshot();
const code = [
`// Navigate back`,
`await page.goBack();`,
];
return { await tab.page.goBack();
code, response.addCode(`// Navigate back`);
captureSnapshot: true, response.addCode(`await page.goBack();`);
waitForNetwork: false, response.addSnapshot(await tab.captureSnapshot());
};
}, },
}); });
@ -81,17 +69,13 @@ const goForward = defineTabTool({
inputSchema: z.object({}), inputSchema: z.object({}),
type: 'readOnly', type: 'readOnly',
}, },
handle: async tab => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
await tab.page.goForward(); await tab.page.goForward();
const code = [ response.addCode(`// Navigate forward`);
`// Navigate forward`, response.addCode(`await page.goForward();`);
`await page.goForward();`, response.addSnapshot(await tab.captureSnapshot());
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
}, },
}); });

View File

@ -30,19 +30,9 @@ const requests = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async tab => { handle: async (tab, params, response) => {
const requests = tab.requests(); const requests = tab.requests();
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n'); [...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
return {
code: [`// <internal code to list network requests>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
}, },
}); });

View File

@ -35,20 +35,12 @@ const pdf = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
response.addCode(`// Save page as ${fileName}`);
const code = [ response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
`// Save page as ${fileName}`, response.addResult(`Saved page as ${fileName}`);
`await page.pdf(${javascript.formatObject({ path: fileName })});`, await tab.page.pdf({ path: fileName });
];
return {
code,
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
captureSnapshot: false,
waitForNetwork: false,
};
}, },
}); });

View File

@ -51,7 +51,7 @@ const screenshot = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
const fileType = params.raw ? 'png' : 'jpeg'; const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`); const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { const options: playwright.PageScreenshotOptions = {
@ -64,36 +64,22 @@ const screenshot = defineTabTool({
const isElementScreenshot = params.element && params.ref; const isElementScreenshot = params.element && params.ref;
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport'); const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
const code = [ response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
`// Screenshot ${screenshotTarget} and save it as ${fileName}`,
];
// Only get snapshot when element screenshot is needed // Only get snapshot when element screenshot is needed
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null; const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator) if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`); response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else else
code.push(`await page.screenshot(${javascript.formatObject(options)});`); response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
const includeBase64 = tab.context.config.imageResponses !== 'omit'; const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
const action = async () => { response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); response.addImage({
return { contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
content: includeBase64 ? [{ data: buffer
type: 'image' as 'image', });
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}] : []
};
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false,
};
} }
}); });

View File

@ -30,14 +30,9 @@ const snapshot = defineTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async context => { handle: async (context, params, response) => {
await context.ensureTab(); const tab = await context.ensureTab();
response.addSnapshot(await tab.captureSnapshot());
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
}, },
}); });
@ -61,26 +56,27 @@ const click = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params); const locator = await tab.refLocator(params);
const button = params.button; const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : ''; const buttonAttr = button ? `{ button: '${button}' }` : '';
const code: string[] = [];
if (params.doubleClick) { if (params.doubleClick) {
code.push(`// Double click ${params.element}`); response.addCode(`// Double click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`); response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
} else { } else {
code.push(`// Click ${params.element}`); response.addCode(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`); response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
} }
return { await tab.run(async () => {
code, if (params.doubleClick)
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }), await locator.dblclick({ button });
captureSnapshot: true, else
waitForNetwork: true, await locator.click({ button });
}; }, response);
}, },
}); });
@ -99,23 +95,19 @@ const drag = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const [startLocator, endLocator] = await tab.refLocators([ const [startLocator, endLocator] = await tab.refLocators([
{ ref: params.startRef, element: params.startElement }, { ref: params.startRef, element: params.startElement },
{ ref: params.endRef, element: params.endElement }, { ref: params.endRef, element: params.endElement },
]); ]);
const code = [ await tab.run(async () => {
`// Drag ${params.startElement} to ${params.endElement}`, await startLocator.dragTo(endLocator);
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});` }, response);
];
return { response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
}, },
}); });
@ -129,20 +121,15 @@ const hover = defineTabTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params); const locator = await tab.refLocator(params);
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
const code = [ await tab.run(async () => {
`// Hover over ${params.element}`, await locator.hover();
`await page.${await generateLocator(locator)}.hover();` }, response);
];
return {
code,
action: () => locator.hover(),
captureSnapshot: true,
waitForNetwork: true,
};
}, },
}); });
@ -160,20 +147,16 @@ const selectOption = defineTabTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (tab, params) => { handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params); const locator = await tab.refLocator(params);
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
const code = [ await tab.run(async () => {
`// Select options [${params.values.join(', ')}] in ${params.element}`, await locator.selectOption(params.values);
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});` }, response);
];
return {
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
}, },
}); });

View File

@ -28,19 +28,9 @@ const listTabs = defineTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async context => { handle: async (context, params, response) => {
await context.ensureTab(); await context.ensureTab();
return { response.setIncludeTabs();
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: (await context.listTabsMarkdown()).join('\n'),
}],
},
};
}, },
}); });
@ -57,17 +47,10 @@ const selectTab = defineTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (context, params) => { handle: async (context, params, response) => {
await context.selectTab(params.index); const tab = await context.selectTab(params.index);
const code = [ response.setIncludeSnapshot();
`// <internal code to select tab ${params.index}>`, response.addSnapshot(await tab.captureSnapshot());
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
}, },
}); });
@ -84,19 +67,13 @@ const newTab = defineTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (context, params) => { handle: async (context, params, response) => {
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);
const code = [ response.setIncludeSnapshot();
`// <internal code to open a new tab>`, response.addSnapshot(await tab.captureSnapshot());
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
}, },
}); });
@ -113,16 +90,12 @@ const closeTab = defineTool({
type: 'destructive', type: 'destructive',
}, },
handle: async (context, params) => { handle: async (context, params, response) => {
await context.closeTab(params.index); await context.closeTab(params.index);
const code = [ response.setIncludeTabs();
`// <internal code to close tab ${params.index}>`, response.addCode(`await myPage.close();`);
]; if (context.tabs().length)
return { response.addSnapshot(await context.currentTabOrDie().captureSnapshot());
code,
captureSnapshot: true,
waitForNetwork: false
};
}, },
}); });

View File

@ -14,12 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod'; import type { z } from 'zod';
import type { Context } from '../context.js'; import type { Context } from '../context.js';
import type * as playwright from 'playwright'; import type * as playwright from 'playwright';
import type { ToolCapability } from '../../config.js'; import type { ToolCapability } from '../../config.js';
import type { Tab } from '../tab.js'; import type { Tab } from '../tab.js';
import type { Response } from '../response.js';
export type ToolSchema<Input extends InputType> = { export type ToolSchema<Input extends InputType> = {
name: string; name: string;
@ -45,21 +45,30 @@ export type DialogModalState = {
export type ModalState = FileUploadModalState | DialogModalState; export type ModalState = FileUploadModalState | DialogModalState;
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void; export type SnapshotContent = {
type: 'snapshot';
snapshot: string;
};
export type ToolResult = { export type TextContent = {
type: 'text';
text: string;
};
export type ImageContent = {
type: 'image';
image: string;
};
export type CodeContent = {
type: 'code';
code: string[]; code: string[];
action?: () => Promise<ToolActionResult>;
captureSnapshot: boolean;
waitForNetwork: boolean;
resultOverride?: ToolActionResult;
}; };
export type Tool<Input extends InputType = InputType> = { export type Tool<Input extends InputType = InputType> = {
capability: ToolCapability; capability: ToolCapability;
schema: ToolSchema<Input>; schema: ToolSchema<Input>;
clearsModalState?: ModalState['type']; handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
}; };
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> { export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
@ -70,20 +79,20 @@ export type TabTool<Input extends InputType = InputType> = {
capability: ToolCapability; capability: ToolCapability;
schema: ToolSchema<Input>; schema: ToolSchema<Input>;
clearsModalState?: ModalState['type']; clearsModalState?: ModalState['type'];
handle: (tab: Tab, params: z.output<Input>) => Promise<ToolResult>; handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
}; };
export function defineTabTool<Input extends InputType>(tool: TabTool<Input>): Tool<Input> { export function defineTabTool<Input extends InputType>(tool: TabTool<Input>): Tool<Input> {
return { return {
...tool, ...tool,
handle: async (context, params) => { handle: async (context, params, response) => {
const tab = context.currentTabOrDie(); const tab = context.currentTabOrDie();
const modalStates = tab.modalStates().map(state => state.type); const modalStates = tab.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n')); throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
if (!tool.clearsModalState && modalStates.length) if (!tool.clearsModalState && modalStates.length)
throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n')); throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
return tool.handle(tab, params); return tool.handle(tab, params, response);
}, },
}; };
} }

View File

@ -32,7 +32,7 @@ const wait = defineTool({
type: 'readOnly', type: 'readOnly',
}, },
handle: async (context, params) => { handle: async (context, params, response) => {
if (!params.text && !params.textGone && !params.time) if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided'); throw new Error('Either time, text or textGone must be provided');
@ -57,11 +57,8 @@ const wait = defineTool({
await locator.waitFor({ state: 'visible' }); await locator.waitFor({ state: 'visible' });
} }
return { response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
code, response.addSnapshot(await tab.captureSnapshot());
captureSnapshot: true,
waitForNetwork: false,
};
}, },
}); });

View File

@ -45,13 +45,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_snapshot', name: 'browser_snapshot',
})).toHaveTextContent(` })).toHaveTextContent(`### Page state
### Ran Playwright code
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD} - Page URL: ${server.HELLO_WORLD}
- Page Title: Title - Page Title: Title
- Page Snapshot: - Page Snapshot:

View File

@ -38,6 +38,7 @@ test('browser_console_messages', async ({ client, server }) => {
name: 'browser_console_messages', name: 'browser_console_messages',
}); });
expect(resource).toHaveTextContent([ expect(resource).toHaveTextContent([
'### Result',
`[LOG] Hello, world! @ ${server.PREFIX}:4`, `[LOG] Hello, world! @ ${server.PREFIX}:4`,
`[ERROR] Error @ ${server.PREFIX}:5`, `[ERROR] Error @ ${server.PREFIX}:5`,
].join('\n')); ].join('\n'));

View File

@ -36,7 +36,8 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\` \`\`\`
### Modal state ### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`); - ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({ const result = await client.callTool({
name: 'browser_handle_dialog', name: 'browser_handle_dialog',
@ -46,17 +47,7 @@ await page.getByRole('button', { name: 'Button' }).click();
}); });
expect(result).not.toContainTextContent('### Modal state'); expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Ran Playwright code expect(result).toContainTextContent(`Page Snapshot:`);
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`);
}); });
test('two alert dialogs', async ({ client, server }) => { test('two alert dialogs', async ({ client, server }) => {
@ -85,7 +76,8 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\` \`\`\`
### Modal state ### Modal state
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`); - ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({ const result = await client.callTool({
name: 'browser_handle_dialog', name: 'browser_handle_dialog',
@ -94,9 +86,9 @@ await page.getByRole('button', { name: 'Button' }).click();
}, },
}); });
expect(result).toContainTextContent(` expect(result).toContainTextContent(`### Modal state
### Modal state - ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`); `);
const result2 = await client.callTool({ const result2 = await client.callTool({
name: 'browser_handle_dialog', name: 'browser_handle_dialog',
@ -138,7 +130,6 @@ test('confirm dialog (true)', async ({ client, server }) => {
}); });
expect(result).not.toContainTextContent('### Modal state'); expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot: expect(result).toContainTextContent(`- Page Snapshot:
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: "true" - generic [active] [ref=e1]: "true"
@ -165,7 +156,8 @@ test('confirm dialog (false)', async ({ client, server }) => {
ref: 'e2', ref: 'e2',
}, },
})).toContainTextContent(`### Modal state })).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({ const result = await client.callTool({
name: 'browser_handle_dialog', name: 'browser_handle_dialog',
@ -200,7 +192,8 @@ test('prompt dialog', async ({ client, server }) => {
ref: 'e2', ref: 'e2',
}, },
})).toContainTextContent(`### Modal state })).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`); - ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({ const result = await client.callTool({
name: 'browser_handle_dialog', name: 'browser_handle_dialog',
@ -236,7 +229,8 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\` \`\`\`
### Modal state ### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`); - ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
const result = await client.callTool({ const result = await client.callTool({
name: 'browser_handle_dialog', name: 'browser_handle_dialog',
@ -246,12 +240,7 @@ await page.getByRole('button', { name: 'Button' }).click();
}); });
expect(result).not.toContainTextContent('### Modal state'); expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Ran Playwright code expect(result).toContainTextContent(`### Page state
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX} - Page URL: ${server.PREFIX}
- Page Title: - Page Title:
- Page Snapshot: - Page Snapshot:

View File

@ -47,7 +47,8 @@ test('browser_evaluate (element)', async ({ client, server }) => {
element: 'body', element: 'body',
ref: 'e1', ref: 'e1',
}, },
})).toContainTextContent(`- Result: "red"`); })).toContainTextContent(`### Result
"red"`);
}); });
test('browser_evaluate (error)', async ({ client, server }) => { test('browser_evaluate (error)', async ({ client, server }) => {

View File

@ -113,8 +113,7 @@ test('clicking on download link emits download', async ({ startClient, server, m
ref: 'e2', ref: 'e2',
}, },
}); });
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(` await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads
### Downloads
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); - Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
}); });
@ -123,7 +122,7 @@ test('navigating to download link emits download', async ({ startClient, server,
config: { outputDir: testInfo.outputPath('output') }, config: { outputDir: testInfo.outputPath('output') },
}); });
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436'); test.skip(mcpBrowser !== 'chromium', 'This test is racy');
server.route('/download', (req, res) => { server.route('/download', (req, res) => {
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/plain', 'Content-Type': 'text/plain',

View File

@ -20,5 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium'); test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_install', name: 'browser_install',
})).toContainTextContent(`No open pages available.`); })).toContainTextContent(`### No open tabs`);
}); });

View File

@ -27,7 +27,13 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_close', name: 'browser_close',
})).toContainTextContent('No open pages available'); })).toContainTextContent(`### Ran Playwright code
\`\`\`js
await page.close()
\`\`\`
### No open tabs
Use the "browser_navigate" tool to navigate to a page first.`);
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',

View File

@ -40,6 +40,7 @@ test('browser_network_requests', async ({ client, server }) => {
await expect.poll(() => client.callTool({ await expect.poll(() => client.callTool({
name: 'browser_network_requests', name: 'browser_network_requests',
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK })).toHaveTextContent(`### Result
[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`); [GET] ${`${server.PREFIX}json`} => [200] OK`);
}); });

View File

@ -65,14 +65,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
arguments: { arguments: {
filename: 'output.pdf', filename: 'output.pdf',
}, },
})).toEqual({ })).toContainTextContent(`output.pdf`);
content: [
{
type: 'text',
text: expect.stringContaining(`output.pdf`),
},
],
});
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)];

View File

@ -31,15 +31,15 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
name: 'browser_take_screenshot', name: 'browser_take_screenshot',
})).toEqual({ })).toEqual({
content: [ content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{ {
data: expect.any(String), data: expect.any(String),
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
type: 'image', type: 'image',
}, },
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
], ],
}); });
}); });
@ -61,15 +61,15 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
}, },
})).toEqual({ })).toEqual({
content: [ content: [
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
{ {
data: expect.any(String), data: expect.any(String),
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
type: 'image', type: 'image',
}, },
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
], ],
}); });
}); });
@ -111,17 +111,17 @@ for (const raw of [undefined, true]) {
arguments: { raw }, arguments: { raw },
})).toEqual({ })).toEqual({
content: [ content: [
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
{ {
text: expect.stringMatching( text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`) new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
), ),
type: 'text', type: 'text',
}, },
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
], ],
}); });
@ -153,15 +153,15 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
}, },
})).toEqual({ })).toEqual({
content: [ content: [
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
{ {
data: expect.any(String), data: expect.any(String),
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
type: 'image', type: 'image',
}, },
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
], ],
}); });
@ -216,15 +216,15 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
arguments: { fullPage: true }, arguments: { fullPage: true },
})).toEqual({ })).toEqual({
content: [ content: [
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text',
},
{ {
data: expect.any(String), data: expect.any(String),
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
type: 'image', type: 'image',
}, },
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text',
},
], ],
}); });
}); });
@ -266,15 +266,15 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
name: 'browser_take_screenshot', name: 'browser_take_screenshot',
})).toEqual({ })).toEqual({
content: [ content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{ {
data: expect.any(String), data: expect.any(String),
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
type: 'image', type: 'image',
}, },
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
], ],
}); });
}); });

View File

@ -44,11 +44,13 @@ test('list first tab', async ({ client }) => {
}); });
test('create new tab', async ({ client }) => { test('create new tab', async ({ client }) => {
expect(await createTab(client, 'Tab one', 'Body one')).toContainTextContent(` const result = await createTab(client, 'Tab one', 'Body one');
### Open tabs expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank) - 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
`);
expect(result).toContainTextContent(`
### Page state ### Page state
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one - Page Title: Tab one
@ -57,12 +59,14 @@ test('create new tab', async ({ client }) => {
- generic [active] [ref=e1]: Body one - generic [active] [ref=e1]: Body one
\`\`\``); \`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(` const result2 = await createTab(client, 'Tab two', 'Body two');
### Open tabs expect(result2).toContainTextContent(`### Open tabs
- 0: [] (about:blank) - 0: [] (about:blank)
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>) - 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
`);
expect(result2).toContainTextContent(`
### Page state ### Page state
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body> - Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two - Page Title: Tab two
@ -82,8 +86,7 @@ test('select tab', async ({ client }) => {
index: 1, index: 1,
}, },
}); });
expect(result).toContainTextContent(` expect(result).toContainTextContent(`### Open tabs
### Open tabs
- 0: [] (about:blank) - 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`); - 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`);
@ -108,8 +111,7 @@ test('close tab', async ({ client }) => {
index: 2, index: 2,
}, },
}); });
expect(result).toContainTextContent(` expect(result).toContainTextContent(`### Open tabs
### Open tabs
- 0: [] (about:blank) - 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`); - 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);