mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-23 22:22:28 +08:00
chore: introduce response type (#738)
This commit is contained in:
parent
c2b98dc70b
commit
601a74305c
@ -19,6 +19,7 @@
|
||||
"build": "tsc",
|
||||
"build:extension": "tsc --project extension",
|
||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"update-readme": "node utils/update-readme.js",
|
||||
"watch": "tsc --watch",
|
||||
"watch:extension": "tsc --watch --project extension",
|
||||
|
@ -19,6 +19,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Context } from './context.js';
|
||||
import { Response } from './response.js';
|
||||
import { allTools } from './tools.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`);
|
||||
|
||||
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) {
|
||||
return errorResult(String(error));
|
||||
}
|
||||
|
@ -59,8 +59,12 @@ export class Context {
|
||||
}
|
||||
|
||||
async selectTab(index: number) {
|
||||
this._currentTab = this._tabs[index];
|
||||
await this._currentTab.page.bringToFront();
|
||||
const tab = this._tabs[index];
|
||||
if (!tab)
|
||||
throw new Error(`Tab ${index} not found`);
|
||||
await tab.page.bringToFront();
|
||||
this._currentTab = tab;
|
||||
return tab;
|
||||
}
|
||||
|
||||
async ensureTab(): Promise<Tab> {
|
||||
@ -70,9 +74,18 @@ export class Context {
|
||||
return this._currentTab!;
|
||||
}
|
||||
|
||||
async listTabsMarkdown(): Promise<string[]> {
|
||||
if (!this._tabs.length)
|
||||
return ['### No tabs open'];
|
||||
async listTabsMarkdown(force: boolean = false): Promise<string[]> {
|
||||
if (this._tabs.length === 1 && !force)
|
||||
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'];
|
||||
for (let i = 0; i < this._tabs.length; i++) {
|
||||
const tab = this._tabs[i];
|
||||
@ -81,62 +94,17 @@ export class Context {
|
||||
const current = tab === this._currentTab ? ' (current)' : '';
|
||||
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
||||
}
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
async closeTab(index: number | undefined) {
|
||||
async closeTab(index: number | undefined): Promise<string> {
|
||||
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||
await tab?.page.close();
|
||||
return await this.listTabsMarkdown();
|
||||
}
|
||||
|
||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||
// 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'),
|
||||
}
|
||||
],
|
||||
};
|
||||
if (!tab)
|
||||
throw new Error(`Tab ${index} not found`);
|
||||
const url = tab.page.url();
|
||||
await tab.page.close();
|
||||
return url;
|
||||
}
|
||||
|
||||
private _onPageCreated(page: playwright.Page) {
|
||||
|
101
src/response.ts
Normal file
101
src/response.ts
Normal 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 };
|
||||
}
|
||||
}
|
73
src/tab.ts
73
src/tab.ts
@ -23,7 +23,7 @@ import { ModalState } from './tools/tool.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
import type { ToolActionResult } from './tools/tool.js';
|
||||
import type { Response } from './response.js';
|
||||
|
||||
type PageEx = playwright.Page & {
|
||||
_snapshotForAI: () => Promise<string>;
|
||||
@ -85,7 +85,7 @@ export class Tab {
|
||||
if (this._modalStates.length === 0)
|
||||
result.push('- There is no modal state present');
|
||||
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`);
|
||||
}
|
||||
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
|
||||
const download = await Promise.race([
|
||||
downloadEvent,
|
||||
new Promise(resolve => setTimeout(resolve, 1000)),
|
||||
new Promise(resolve => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
if (!download)
|
||||
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.
|
||||
@ -169,40 +172,53 @@ export class Tab {
|
||||
return this._requests;
|
||||
}
|
||||
|
||||
takeRecentConsoleMarkdown(): string[] {
|
||||
private _takeRecentConsoleMarkdown(): string[] {
|
||||
if (!this._recentConsoleMessages.length)
|
||||
return [];
|
||||
const result = this._recentConsoleMessages.map(message => {
|
||||
return `- ${trim(message.toString(), 100)}`;
|
||||
});
|
||||
return ['', `### New console messages`, ...result];
|
||||
return [`### New console messages`, ...result, ''];
|
||||
}
|
||||
|
||||
listDownloadsMarkdown(): string[] {
|
||||
private _listDownloadsMarkdown(): string[] {
|
||||
if (!this._downloads.length)
|
||||
return [];
|
||||
|
||||
const result: string[] = ['', '### Downloads'];
|
||||
const result: string[] = ['### Downloads'];
|
||||
for (const entry of this._downloads) {
|
||||
if (entry.finished)
|
||||
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||
else
|
||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||
}
|
||||
result.push('');
|
||||
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();
|
||||
return [
|
||||
`### Page state`,
|
||||
`- Page URL: ${this.page.url()}`,
|
||||
`- Page Title: ${await this.page.title()}`,
|
||||
`- Page Snapshot:`,
|
||||
'```yaml',
|
||||
snapshot,
|
||||
'```',
|
||||
].join('\n');
|
||||
result.push(
|
||||
`### Page state`,
|
||||
`- Page URL: ${this.page.url()}`,
|
||||
`- Page Title: ${await this.page.title()}`,
|
||||
`- Page Snapshot:`,
|
||||
'```yaml',
|
||||
snapshot,
|
||||
'```',
|
||||
);
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
private _javaScriptBlocked(): boolean {
|
||||
@ -226,20 +242,25 @@ export class Tab {
|
||||
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;
|
||||
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
||||
await this._raceAgainstModalDialogs(async () => {
|
||||
try {
|
||||
if (options.waitForNetwork)
|
||||
return await waitForCompletion(this, async () => callback?.()) ?? undefined;
|
||||
if (response.includeSnapshot())
|
||||
await waitForCompletion(this, callback);
|
||||
else
|
||||
return await callback?.() ?? undefined;
|
||||
await callback();
|
||||
} 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> {
|
||||
@ -247,7 +268,7 @@ export class Tab {
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (!snapshot.includes(`[ref=${param.ref}]`))
|
||||
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
|
||||
|
@ -28,13 +28,10 @@ const close = defineTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
handle: async (context, params, response) => {
|
||||
await context.close();
|
||||
return {
|
||||
code: [`await page.close()`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
response.setIncludeTabs();
|
||||
response.addCode(`await page.close()`);
|
||||
},
|
||||
});
|
||||
|
||||
@ -51,22 +48,13 @@ const resize = defineTabTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
const code = [
|
||||
`// Resize browser window to ${params.width}x${params.height}`,
|
||||
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
||||
];
|
||||
handle: async (tab, params, response) => {
|
||||
response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
|
||||
response.addCode(`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 });
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true
|
||||
};
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -26,19 +26,8 @@ const console = defineTabTool({
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async tab => {
|
||||
const messages = tab.consoleMessages();
|
||||
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,
|
||||
};
|
||||
handle: async (tab, params, response) => {
|
||||
tab.consoleMessages().map(message => response.addResult(message.toString()));
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -31,27 +31,20 @@ const handleDialog = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
|
||||
if (!dialogState)
|
||||
throw new Error('No dialog visible');
|
||||
|
||||
if (params.accept)
|
||||
await dialogState.dialog.accept(params.promptText);
|
||||
else
|
||||
await dialogState.dialog.dismiss();
|
||||
|
||||
tab.clearModalState(dialogState);
|
||||
|
||||
const code = [
|
||||
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
await tab.run(async () => {
|
||||
if (params.accept)
|
||||
await dialogState.dialog.accept(params.promptText);
|
||||
else
|
||||
await dialogState.dialog.dismiss();
|
||||
}, response);
|
||||
},
|
||||
|
||||
clearsModalState: 'dialog',
|
||||
|
@ -38,29 +38,22 @@ const evaluate = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
const code: string[] = [];
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
let locator: playwright.Locator | undefined;
|
||||
if (params.ref && 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 {
|
||||
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: async () => {
|
||||
const receiver = locator ?? tab.page as any;
|
||||
const result = await receiver._evaluateFunction(params.function);
|
||||
return {
|
||||
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
await tab.run(async () => {
|
||||
const receiver = locator ?? tab.page as any;
|
||||
const result = await receiver._evaluateFunction(params.function);
|
||||
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -30,26 +30,20 @@ const uploadFile = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
|
||||
if (!modalState)
|
||||
throw new Error('No file chooser visible');
|
||||
|
||||
const code = [
|
||||
`// <internal code to chose files ${params.paths.join(', ')}`,
|
||||
];
|
||||
response.addCode(`// Select files for upload`);
|
||||
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
||||
|
||||
const action = async () => {
|
||||
await tab.run(async () => {
|
||||
await modalState.fileChooser.setFiles(params.paths);
|
||||
tab.clearModalState(modalState);
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
}, response);
|
||||
},
|
||||
clearsModalState: 'fileChooser',
|
||||
});
|
||||
|
@ -31,7 +31,7 @@ const install = defineTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
handle: async (context, params, response) => {
|
||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||
@ -49,11 +49,7 @@ const install = defineTool({
|
||||
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||
});
|
||||
});
|
||||
return {
|
||||
code: [`// Browser ${channel} installed`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
response.setIncludeTabs();
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -34,20 +34,14 @@ const pressKey = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
const code = [
|
||||
`// Press ${params.key}`,
|
||||
`await page.keyboard.press('${params.key}');`,
|
||||
];
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`// Press ${params.key}`);
|
||||
response.addCode(`await page.keyboard.press('${params.key}');`);
|
||||
|
||||
const action = () => tab.page.keyboard.press(params.key);
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true
|
||||
};
|
||||
await tab.run(async () => {
|
||||
await tab.page.keyboard.press(params.key);
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
@ -67,34 +61,27 @@ const type = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const locator = await tab.refLocator(params);
|
||||
|
||||
const code: string[] = [];
|
||||
const steps: (() => Promise<void>)[] = [];
|
||||
await tab.run(async () => {
|
||||
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) {
|
||||
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||
steps.push(() => locator.pressSequentially(params.text));
|
||||
} else {
|
||||
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,
|
||||
};
|
||||
if (params.submit) {
|
||||
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||
await locator.press('Enter');
|
||||
}
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -34,18 +34,13 @@ const mouseMove = defineTabTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
const code = [
|
||||
`// Move mouse to (${params.x}, ${params.y})`,
|
||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||
];
|
||||
const action = () => tab.page.mouse.move(params.x, params.y);
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false
|
||||
};
|
||||
handle: async (tab, params, response) => {
|
||||
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
|
||||
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||
|
||||
await tab.run(async () => {
|
||||
await tab.page.mouse.move(params.x, params.y);
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
@ -62,24 +57,19 @@ const mouseClick = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
const code = [
|
||||
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||
`await page.mouse.down();`,
|
||||
`await page.mouse.up();`,
|
||||
];
|
||||
const action = async () => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
|
||||
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||
response.addCode(`await page.mouse.down();`);
|
||||
response.addCode(`await page.mouse.up();`);
|
||||
|
||||
await tab.run(async () => {
|
||||
await tab.page.mouse.move(params.x, params.y);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.up();
|
||||
};
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
@ -98,28 +88,21 @@ const mouseDrag = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
const code = [
|
||||
`// 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();`,
|
||||
];
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
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.down();
|
||||
await tab.page.mouse.move(params.endX, params.endY);
|
||||
await tab.page.mouse.up();
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -30,20 +30,13 @@ const navigate = defineTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.ensureTab();
|
||||
await tab.navigate(params.url);
|
||||
|
||||
const code = [
|
||||
`// Navigate to ${params.url}`,
|
||||
`await page.goto('${params.url}');`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
response.addCode(`// Navigate to ${params.url}`);
|
||||
response.addCode(`await page.goto('${params.url}');`);
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
@ -57,18 +50,13 @@ const goBack = defineTabTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async tab => {
|
||||
await tab.page.goBack();
|
||||
const code = [
|
||||
`// Navigate back`,
|
||||
`await page.goBack();`,
|
||||
];
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
await tab.page.goBack();
|
||||
response.addCode(`// Navigate back`);
|
||||
response.addCode(`await page.goBack();`);
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
@ -81,17 +69,13 @@ const goForward = defineTabTool({
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async tab => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
await tab.page.goForward();
|
||||
const code = [
|
||||
`// Navigate forward`,
|
||||
`await page.goForward();`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
response.addCode(`// Navigate forward`);
|
||||
response.addCode(`await page.goForward();`);
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -30,19 +30,9 @@ const requests = defineTabTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async tab => {
|
||||
handle: async (tab, params, response) => {
|
||||
const requests = tab.requests();
|
||||
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
||||
return {
|
||||
code: [`// <internal code to list network requests>`],
|
||||
action: async () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: log }]
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -35,20 +35,12 @@ const pdf = defineTabTool({
|
||||
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 code = [
|
||||
`// Save page as ${fileName}`,
|
||||
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
response.addCode(`// Save page as ${fileName}`);
|
||||
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
||||
response.addResult(`Saved page as ${fileName}`);
|
||||
await tab.page.pdf({ path: fileName });
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -51,7 +51,7 @@ const screenshot = defineTabTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
const fileType = params.raw ? 'png' : 'jpeg';
|
||||
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||
const options: playwright.PageScreenshotOptions = {
|
||||
@ -64,36 +64,22 @@ const screenshot = defineTabTool({
|
||||
const isElementScreenshot = params.element && params.ref;
|
||||
|
||||
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
|
||||
const code = [
|
||||
`// Screenshot ${screenshotTarget} and save it as ${fileName}`,
|
||||
];
|
||||
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
|
||||
|
||||
// Only get snapshot when element screenshot is needed
|
||||
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||
|
||||
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
|
||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||
|
||||
const includeBase64 = tab.context.config.imageResponses !== 'omit';
|
||||
const action = async () => {
|
||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||
return {
|
||||
content: includeBase64 ? [{
|
||||
type: 'image' as 'image',
|
||||
data: screenshot.toString('base64'),
|
||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||
}] : []
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
||||
response.addImage({
|
||||
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||
data: buffer
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -30,14 +30,9 @@ const snapshot = defineTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
await context.ensureTab();
|
||||
|
||||
return {
|
||||
code: [`// <internal code to capture accessibility snapshot>`],
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.ensureTab();
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
@ -61,26 +56,27 @@ const click = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const locator = await tab.refLocator(params);
|
||||
const button = params.button;
|
||||
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||
|
||||
const code: string[] = [];
|
||||
if (params.doubleClick) {
|
||||
code.push(`// Double click ${params.element}`);
|
||||
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||
response.addCode(`// Double click ${params.element}`);
|
||||
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||
} else {
|
||||
code.push(`// Click ${params.element}`);
|
||||
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||
response.addCode(`// Click ${params.element}`);
|
||||
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
await tab.run(async () => {
|
||||
if (params.doubleClick)
|
||||
await locator.dblclick({ button });
|
||||
else
|
||||
await locator.click({ button });
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
@ -99,23 +95,19 @@ const drag = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const [startLocator, endLocator] = await tab.refLocators([
|
||||
{ ref: params.startRef, element: params.startElement },
|
||||
{ ref: params.endRef, element: params.endElement },
|
||||
]);
|
||||
|
||||
const code = [
|
||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
||||
];
|
||||
await tab.run(async () => {
|
||||
await startLocator.dragTo(endLocator);
|
||||
}, response);
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => startLocator.dragTo(endLocator),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
|
||||
},
|
||||
});
|
||||
|
||||
@ -129,20 +121,15 @@ const hover = defineTabTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const locator = await tab.refLocator(params);
|
||||
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
|
||||
|
||||
const code = [
|
||||
`// Hover over ${params.element}`,
|
||||
`await page.${await generateLocator(locator)}.hover();`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.hover(),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
await tab.run(async () => {
|
||||
await locator.hover();
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
@ -160,20 +147,16 @@ const selectOption = defineTabTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params) => {
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
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 = [
|
||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.selectOption(params.values).then(() => {}),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
await tab.run(async () => {
|
||||
await locator.selectOption(params.values);
|
||||
}, response);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -28,19 +28,9 @@ const listTabs = defineTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
handle: async (context, params, response) => {
|
||||
await context.ensureTab();
|
||||
return {
|
||||
code: [`// <internal code to list tabs>`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
resultOverride: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: (await context.listTabsMarkdown()).join('\n'),
|
||||
}],
|
||||
},
|
||||
};
|
||||
response.setIncludeTabs();
|
||||
},
|
||||
});
|
||||
|
||||
@ -57,17 +47,10 @@ const selectTab = defineTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
await context.selectTab(params.index);
|
||||
const code = [
|
||||
`// <internal code to select tab ${params.index}>`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.selectTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
@ -84,19 +67,13 @@ const newTab = defineTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.newTab();
|
||||
if (params.url)
|
||||
await tab.navigate(params.url);
|
||||
|
||||
const code = [
|
||||
`// <internal code to open a new tab>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
response.setIncludeSnapshot();
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
@ -113,16 +90,12 @@ const closeTab = defineTool({
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
handle: async (context, params, response) => {
|
||||
await context.closeTab(params.index);
|
||||
const code = [
|
||||
`// <internal code to close tab ${params.index}>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
response.setIncludeTabs();
|
||||
response.addCode(`await myPage.close();`);
|
||||
if (context.tabs().length)
|
||||
response.addSnapshot(await context.currentTabOrDie().captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -14,12 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { z } from 'zod';
|
||||
import type { Context } from '../context.js';
|
||||
import type * as playwright from 'playwright';
|
||||
import type { ToolCapability } from '../../config.js';
|
||||
import type { Tab } from '../tab.js';
|
||||
import type { Response } from '../response.js';
|
||||
|
||||
export type ToolSchema<Input extends InputType> = {
|
||||
name: string;
|
||||
@ -45,21 +45,30 @@ export type 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[];
|
||||
action?: () => Promise<ToolActionResult>;
|
||||
captureSnapshot: boolean;
|
||||
waitForNetwork: boolean;
|
||||
resultOverride?: ToolActionResult;
|
||||
};
|
||||
|
||||
export type Tool<Input extends InputType = InputType> = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema<Input>;
|
||||
clearsModalState?: ModalState['type'];
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
||||
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
|
||||
};
|
||||
|
||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||
@ -70,20 +79,20 @@ export type TabTool<Input extends InputType = InputType> = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema<Input>;
|
||||
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> {
|
||||
return {
|
||||
...tool,
|
||||
handle: async (context, params) => {
|
||||
handle: async (context, params, response) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const modalStates = tab.modalStates().map(state => state.type);
|
||||
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'));
|
||||
if (!tool.clearsModalState && modalStates.length)
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ const wait = defineTool({
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
handle: async (context, params, response) => {
|
||||
if (!params.text && !params.textGone && !params.time)
|
||||
throw new Error('Either time, text or textGone must be provided');
|
||||
|
||||
@ -57,11 +57,8 @@ const wait = defineTool({
|
||||
await locator.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
|
||||
response.addSnapshot(await tab.captureSnapshot());
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -45,13 +45,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
})).toHaveTextContent(`
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to capture accessibility snapshot>
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
})).toHaveTextContent(`### Page state
|
||||
- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
|
@ -38,6 +38,7 @@ test('browser_console_messages', async ({ client, server }) => {
|
||||
name: 'browser_console_messages',
|
||||
});
|
||||
expect(resource).toHaveTextContent([
|
||||
'### Result',
|
||||
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
|
||||
`[ERROR] Error @ ${server.PREFIX}:5`,
|
||||
].join('\n'));
|
||||
|
@ -36,7 +36,8 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
\`\`\`
|
||||
|
||||
### 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({
|
||||
name: 'browser_handle_dialog',
|
||||
@ -46,17 +47,7 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toContainTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to handle "alert" dialog>
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title:
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- button "Button"`);
|
||||
expect(result).toContainTextContent(`Page Snapshot:`);
|
||||
});
|
||||
|
||||
test('two alert dialogs', async ({ client, server }) => {
|
||||
@ -85,7 +76,8 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
\`\`\`
|
||||
|
||||
### 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({
|
||||
name: 'browser_handle_dialog',
|
||||
@ -94,9 +86,9 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContainTextContent(`
|
||||
### Modal state
|
||||
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
expect(result).toContainTextContent(`### Modal state
|
||||
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool
|
||||
`);
|
||||
|
||||
const result2 = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
@ -138,7 +130,6 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||
expect(result).toContainTextContent(`- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [active] [ref=e1]: "true"
|
||||
@ -165,7 +156,8 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
||||
ref: 'e2',
|
||||
},
|
||||
})).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({
|
||||
name: 'browser_handle_dialog',
|
||||
@ -200,7 +192,8 @@ test('prompt dialog', async ({ client, server }) => {
|
||||
ref: 'e2',
|
||||
},
|
||||
})).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({
|
||||
name: 'browser_handle_dialog',
|
||||
@ -236,7 +229,8 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
\`\`\`
|
||||
|
||||
### 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({
|
||||
name: 'browser_handle_dialog',
|
||||
@ -246,12 +240,7 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toContainTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to handle "alert" dialog>
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
expect(result).toContainTextContent(`### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title:
|
||||
- Page Snapshot:
|
||||
|
@ -47,7 +47,8 @@ test('browser_evaluate (element)', async ({ client, server }) => {
|
||||
element: 'body',
|
||||
ref: 'e1',
|
||||
},
|
||||
})).toContainTextContent(`- Result: "red"`);
|
||||
})).toContainTextContent(`### Result
|
||||
"red"`);
|
||||
});
|
||||
|
||||
test('browser_evaluate (error)', async ({ client, server }) => {
|
||||
|
@ -113,8 +113,7 @@ test('clicking on download link emits download', async ({ startClient, server, m
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
||||
### Downloads
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads
|
||||
- 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') },
|
||||
});
|
||||
|
||||
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) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
|
@ -20,5 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_install',
|
||||
})).toContainTextContent(`No open pages available.`);
|
||||
})).toContainTextContent(`### No open tabs`);
|
||||
});
|
||||
|
@ -27,7 +27,13 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
||||
|
||||
expect(await client.callTool({
|
||||
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({
|
||||
name: 'browser_navigate',
|
||||
|
@ -40,6 +40,7 @@ test('browser_network_requests', async ({ client, server }) => {
|
||||
|
||||
await expect.poll(() => client.callTool({
|
||||
name: 'browser_network_requests',
|
||||
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||
})).toHaveTextContent(`### Result
|
||||
[GET] ${`${server.PREFIX}`} => [200] OK
|
||||
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
||||
});
|
||||
|
@ -65,14 +65,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
|
||||
arguments: {
|
||||
filename: 'output.pdf',
|
||||
},
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: expect.stringContaining(`output.pdf`),
|
||||
},
|
||||
],
|
||||
});
|
||||
})).toContainTextContent(`output.pdf`);
|
||||
|
||||
const files = [...fs.readdirSync(outputDir)];
|
||||
|
||||
|
@ -31,15 +31,15 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
|
||||
name: 'browser_take_screenshot',
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
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({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@ -111,17 +111,17 @@ for (const raw of [undefined, true]) {
|
||||
arguments: { raw },
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: `image/${ext}`,
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
text: expect.stringMatching(
|
||||
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
|
||||
),
|
||||
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({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`output.jpeg`),
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
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 },
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot full page and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
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',
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
@ -44,11 +44,13 @@ test('list first tab', async ({ client }) => {
|
||||
});
|
||||
|
||||
test('create new tab', async ({ client }) => {
|
||||
expect(await createTab(client, 'Tab one', 'Body one')).toContainTextContent(`
|
||||
### Open tabs
|
||||
const result = await createTab(client, 'Tab one', 'Body one');
|
||||
expect(result).toContainTextContent(`### Open tabs
|
||||
- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
`);
|
||||
|
||||
expect(result).toContainTextContent(`
|
||||
### Page state
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
@ -57,12 +59,14 @@ test('create new tab', async ({ client }) => {
|
||||
- generic [active] [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(`
|
||||
### Open tabs
|
||||
const result2 = await createTab(client, 'Tab two', 'Body two');
|
||||
expect(result2).toContainTextContent(`### Open tabs
|
||||
- 0: [] (about:blank)
|
||||
- 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>)
|
||||
`);
|
||||
|
||||
expect(result2).toContainTextContent(`
|
||||
### Page state
|
||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||
- Page Title: Tab two
|
||||
@ -82,8 +86,7 @@ test('select tab', async ({ client }) => {
|
||||
index: 1,
|
||||
},
|
||||
});
|
||||
expect(result).toContainTextContent(`
|
||||
### Open tabs
|
||||
expect(result).toContainTextContent(`### Open tabs
|
||||
- 0: [] (about:blank)
|
||||
- 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>)`);
|
||||
@ -108,8 +111,7 @@ test('close tab', async ({ client }) => {
|
||||
index: 2,
|
||||
},
|
||||
});
|
||||
expect(result).toContainTextContent(`
|
||||
### Open tabs
|
||||
expect(result).toContainTextContent(`### Open tabs
|
||||
- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user