From 601a74305c2a81582bb99114858003bac3601c52 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 22 Jul 2025 16:36:21 -0700 Subject: [PATCH] chore: introduce response type (#738) --- package.json | 1 + src/connection.ts | 5 +- src/context.ts | 82 ++++++++++--------------------- src/response.ts | 101 +++++++++++++++++++++++++++++++++++++++ src/tab.ts | 73 ++++++++++++++++++---------- src/tools/common.ts | 28 ++++------- src/tools/console.ts | 15 +----- src/tools/dialogs.ts | 25 ++++------ src/tools/evaluate.ts | 25 ++++------ src/tools/files.ts | 20 +++----- src/tools/install.ts | 8 +--- src/tools/keyboard.ts | 63 ++++++++++-------------- src/tools/mouse.ts | 71 +++++++++++---------------- src/tools/navigate.ts | 48 +++++++------------ src/tools/network.ts | 14 +----- src/tools/pdf.ts | 18 ++----- src/tools/screenshot.ts | 34 ++++--------- src/tools/snapshot.ts | 93 +++++++++++++++-------------------- src/tools/tabs.ts | 55 ++++++--------------- src/tools/tool.ts | 33 ++++++++----- src/tools/wait.ts | 9 ++-- tests/cdp.spec.ts | 8 +--- tests/console.spec.ts | 1 + tests/dialogs.spec.ts | 41 ++++++---------- tests/evaluate.spec.ts | 3 +- tests/files.spec.ts | 5 +- tests/install.spec.ts | 2 +- tests/launch.spec.ts | 8 +++- tests/network.spec.ts | 3 +- tests/pdf.spec.ts | 9 +--- tests/screenshot.spec.ts | 50 +++++++++---------- tests/tabs.spec.ts | 18 +++---- 32 files changed, 443 insertions(+), 526 deletions(-) create mode 100644 src/response.ts diff --git a/package.json b/package.json index 3a46577..f65e417 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/connection.ts b/src/connection.ts index 513c176..570dc00 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -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)); } diff --git a/src/context.ts b/src/context.ts index 6fa4218..6c1f70f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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 { @@ -70,9 +74,18 @@ export class Context { return this._currentTab!; } - async listTabsMarkdown(): Promise { - if (!this._tabs.length) - return ['### No tabs open']; + async listTabsMarkdown(force: boolean = false): Promise { + 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 { const tab = index === undefined ? this._currentTab : this._tabs[index]; - await tab?.page.close(); - return await this.listTabsMarkdown(); - } - - async run(tool: Tool, params: Record | 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) { diff --git a/src/response.ts b/src/response.ts new file mode 100644 index 0000000..a075eed --- /dev/null +++ b/src/response.ts @@ -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 }; + } +} diff --git a/src/tab.ts b/src/tab.ts index 8f6ea23..3dc393e 100644 --- a/src/tab.ts +++ b/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; @@ -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 { + async captureSnapshot(options: { omitAriaSnapshot?: boolean } = {}): Promise { + 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, options: { waitForNetwork?: boolean, captureSnapshot?: boolean }): Promise<{ actionResult: ToolActionResult | undefined, snapshot: string | undefined }> { + async run(callback: () => Promise, 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 { @@ -247,7 +268,7 @@ export class Tab { } async refLocators(params: { element: string, ref: string }[]): Promise { - 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.`); diff --git a/src/tools/common.ts b/src/tools/common.ts index 614ae3b..b559c70 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -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); }, }); diff --git a/src/tools/console.ts b/src/tools/console.ts index 02f9aa7..cfed3ab 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -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: [`// `], - 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())); }, }); diff --git a/src/tools/dialogs.ts b/src/tools/dialogs.ts index 4527a47..a5ccf18 100644 --- a/src/tools/dialogs.ts +++ b/src/tools/dialogs.ts @@ -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 = [ - `// `, - ]; - - 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', diff --git a/src/tools/evaluate.ts b/src/tools/evaluate.ts index 7097cb8..8452249 100644 --- a/src/tools/evaluate.ts +++ b/src/tools/evaluate.ts @@ -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); }, }); diff --git a/src/tools/files.ts b/src/tools/files.ts index f1f0bdb..6ecf43b 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -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 = [ - `// { + await tab.run(async () => { await modalState.fileChooser.setFiles(params.paths); tab.clearModalState(modalState); - }; - - return { - code, - action, - captureSnapshot: true, - waitForNetwork: true, - }; + }, response); }, clearsModalState: 'fileChooser', }); diff --git a/src/tools/install.ts b/src/tools/install.ts index 43fe1c4..3e4efcd 100644 --- a/src/tools/install.ts +++ b/src/tools/install.ts @@ -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(); }, }); diff --git a/src/tools/keyboard.ts b/src/tools/keyboard.ts index ffb20cc..1e143f7 100644 --- a/src/tools/keyboard.ts +++ b/src/tools/keyboard.ts @@ -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)[] = []; + 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); }, }); diff --git a/src/tools/mouse.ts b/src/tools/mouse.ts index 8015484..a86e90c 100644 --- a/src/tools/mouse.ts +++ b/src/tools/mouse.ts @@ -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); }, }); diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts index 581550f..afcbffc 100644 --- a/src/tools/navigate.ts +++ b/src/tools/navigate.ts @@ -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()); }, }); diff --git a/src/tools/network.ts b/src/tools/network.ts index 1874305..7352153 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -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: [`// `], - action: async () => { - return { - content: [{ type: 'text', text: log }] - }; - }, - captureSnapshot: false, - waitForNetwork: false, - }; + [...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res))); }, }); diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index 0d40a69..d68b11d 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -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 }); }, }); diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 317c8c9..7df12db 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -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 + }); } }); diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 2534b9b..e855ad4 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -30,14 +30,9 @@ const snapshot = defineTool({ type: 'readOnly', }, - handle: async context => { - await context.ensureTab(); - - return { - code: [`// `], - 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); }, }); diff --git a/src/tools/tabs.ts b/src/tools/tabs.ts index d0659b3..ff1a8b7 100644 --- a/src/tools/tabs.ts +++ b/src/tools/tabs.ts @@ -28,19 +28,9 @@ const listTabs = defineTool({ type: 'readOnly', }, - handle: async context => { + handle: async (context, params, response) => { await context.ensureTab(); - return { - code: [`// `], - 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 = [ - `// `, - ]; - - 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 = [ - `// `, - ]; - 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 = [ - `// `, - ]; - return { - code, - captureSnapshot: true, - waitForNetwork: false - }; + response.setIncludeTabs(); + response.addCode(`await myPage.close();`); + if (context.tabs().length) + response.addSnapshot(await context.currentTabOrDie().captureSnapshot()); }, }); diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 628df7f..8f2a738 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -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 = { 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; - captureSnapshot: boolean; - waitForNetwork: boolean; - resultOverride?: ToolActionResult; }; export type Tool = { capability: ToolCapability; schema: ToolSchema; - clearsModalState?: ModalState['type']; - handle: (context: Context, params: z.output) => Promise; + handle: (context: Context, params: z.output, response: Response) => Promise; }; export function defineTool(tool: Tool): Tool { @@ -70,20 +79,20 @@ export type TabTool = { capability: ToolCapability; schema: ToolSchema; clearsModalState?: ModalState['type']; - handle: (tab: Tab, params: z.output) => Promise; + handle: (tab: Tab, params: z.output, response: Response) => Promise; }; export function defineTabTool(tool: TabTool): Tool { 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); }, }; } diff --git a/src/tools/wait.ts b/src/tools/wait.ts index 519148d..a83d022 100644 --- a/src/tools/wait.ts +++ b/src/tools/wait.ts @@ -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()); }, }); diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts index d64aebb..c919600 100644 --- a/tests/cdp.spec.ts +++ b/tests/cdp.spec.ts @@ -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 -// -\`\`\` - -### Page state + })).toHaveTextContent(`### Page state - Page URL: ${server.HELLO_WORLD} - Page Title: Title - Page Snapshot: diff --git a/tests/console.spec.ts b/tests/console.spec.ts index e94ecf7..e5ee045 100644 --- a/tests/console.spec.ts +++ b/tests/console.spec.ts @@ -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')); diff --git a/tests/dialogs.spec.ts b/tests/dialogs.spec.ts index 15cfb8a..dada6b1 100644 --- a/tests/dialogs.spec.ts +++ b/tests/dialogs.spec.ts @@ -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 -// -\`\`\` - -### 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('// '); 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 -// -\`\`\` - -### Page state + expect(result).toContainTextContent(`### Page state - Page URL: ${server.PREFIX} - Page Title: - Page Snapshot: diff --git a/tests/evaluate.spec.ts b/tests/evaluate.spec.ts index d72e513..f1355a6 100644 --- a/tests/evaluate.spec.ts +++ b/tests/evaluate.spec.ts @@ -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 }) => { diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 63f7d9d..bf43795 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -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', diff --git a/tests/install.spec.ts b/tests/install.spec.ts index 66a11d5..a7cd1a3 100644 --- a/tests/install.spec.ts +++ b/tests/install.spec.ts @@ -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`); }); diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index 09d09ae..25cf2b2 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -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', diff --git a/tests/network.spec.ts b/tests/network.spec.ts index 56e71c0..a8eb492 100644 --- a/tests/network.spec.ts +++ b/tests/network.spec.ts @@ -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`); }); diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts index c3cc901..aa65020 100644 --- a/tests/pdf.spec.ts +++ b/tests/pdf.spec.ts @@ -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)]; diff --git a/tests/screenshot.spec.ts b/tests/screenshot.spec.ts index a90ae69..e329a9f 100644 --- a/tests/screenshot.spec.ts +++ b/tests/screenshot.spec.ts @@ -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', - }, ], }); }); diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts index 29394fb..6f5dced 100644 --- a/tests/tabs.spec.ts +++ b/tests/tabs.spec.ts @@ -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,Tab oneBody one) +`); + expect(result).toContainTextContent(` ### Page state - Page URL: data:text/html,Tab oneBody one - 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,Tab oneBody one) - 2: (current) [Tab two] (data:text/html,Tab twoBody two) +`); + expect(result2).toContainTextContent(` ### Page state - Page URL: data:text/html,Tab twoBody two - 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,Tab oneBody one) - 2: [Tab two] (data:text/html,Tab twoBody two)`); @@ -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,Tab oneBody one)`);