diff --git a/package-lock.json b/package-lock.json index f23a90c..b47a0a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "playwright": "1.55.0-alpha-1752701791000", "playwright-core": "1.55.0-alpha-1752701791000", "ws": "^8.18.1", + "zod": "^3.24.1", "zod-to-json-schema": "^3.24.4" }, "bin": { diff --git a/package.json b/package.json index 91a7d1f..1357c43 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "playwright": "1.55.0-alpha-1752701791000", "playwright-core": "1.55.0-alpha-1752701791000", "ws": "^8.18.1", + "zod": "^3.24.1", "zod-to-json-schema": "^3.24.4" }, "devDependencies": { diff --git a/src/context.ts b/src/context.ts index 5165c8c..42c9c3f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -93,8 +93,8 @@ export class Context { if (!this._tabs.length) { return [ - '### No open tabs', - 'Use the "browser_navigate" tool to navigate to a page first.', + '### Open tabs', + 'No open tabs. Use the "browser_navigate" tool to navigate to a page first.', '', ]; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1d4e2ba..a627022 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -88,12 +88,12 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser } const errorResult = (...messages: string[]) => ({ - content: [{ type: 'text', text: messages.join('\n') }], + content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }], isError: true, }); const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema; if (!tool) - return errorResult(`Tool "${request.params.name}" not found`); + return errorResult(`Error: Tool "${request.params.name}" not found`); try { return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {})); diff --git a/src/response.ts b/src/response.ts index b2f6d56..8cd0c8f 100644 --- a/src/response.ts +++ b/src/response.ts @@ -28,6 +28,7 @@ export class Response { readonly toolName: string; readonly toolArgs: Record; + private _isError: boolean | undefined; constructor(context: Context, toolName: string, toolArgs: Record) { this._context = context; @@ -39,6 +40,11 @@ export class Response { this._result.push(result); } + addError(error: string) { + this._result.push(`Error: ${error}`); + this._isError = true; + } + result() { return this._result.join('\n'); } @@ -77,7 +83,7 @@ export class Response { return this._snapshot; } - async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> { + async serialize(): Promise<{ content: (TextContent | ImageContent)[], isError?: boolean }> { const response: string[] = []; // Start with command result. @@ -116,6 +122,6 @@ ${this._code.join('\n')} content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType }); } - return { content }; + return { content, isError: this._isError }; } } diff --git a/src/tools/common.ts b/src/tools/common.ts index 337f4ba..00f950b 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -49,7 +49,6 @@ const resize = defineTabTool({ }, 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} });`); await tab.waitForCompletion(async () => { diff --git a/src/tools/keyboard.ts b/src/tools/keyboard.ts index c40b83f..12e6e45 100644 --- a/src/tools/keyboard.ts +++ b/src/tools/keyboard.ts @@ -67,11 +67,9 @@ const type = defineTabTool({ await tab.waitForCompletion(async () => { if (params.slowly) { response.setIncludeSnapshot(); - 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); } diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts index f69d8e2..6489ce8 100644 --- a/src/tools/navigate.ts +++ b/src/tools/navigate.ts @@ -35,7 +35,6 @@ const navigate = defineTool({ await tab.navigate(params.url); response.setIncludeSnapshot(); - response.addCode(`// Navigate to ${params.url}`); response.addCode(`await page.goto('${params.url}');`); }, }); @@ -53,7 +52,6 @@ const goBack = defineTabTool({ handle: async (tab, params, response) => { await tab.page.goBack(); response.setIncludeSnapshot(); - response.addCode(`// Navigate back`); response.addCode(`await page.goBack();`); }, }); @@ -70,7 +68,6 @@ const goForward = defineTabTool({ handle: async (tab, params, response) => { await tab.page.goForward(); response.setIncludeSnapshot(); - response.addCode(`// Navigate forward`); response.addCode(`await page.goForward();`); }, }); diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index d68b11d..5a10ae5 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -37,7 +37,6 @@ const pdf = defineTabTool({ handle: async (tab, params, response) => { const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); - 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/snapshot.ts b/src/tools/snapshot.ts index 083f0a7..e8694d9 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -63,13 +63,11 @@ const click = defineTabTool({ const button = params.button; const buttonAttr = button ? `{ button: '${button}' }` : ''; - if (params.doubleClick) { - response.addCode(`// Double click ${params.element}`); + if (params.doubleClick) response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`); - } else { - response.addCode(`// Click ${params.element}`); + else response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`); - } + await tab.waitForCompletion(async () => { if (params.doubleClick) @@ -151,7 +149,6 @@ const selectOption = defineTabTool({ 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)});`); await tab.waitForCompletion(async () => { diff --git a/src/tools/tool.ts b/src/tools/tool.ts index aa0628b..4733507 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -60,10 +60,11 @@ export function defineTabTool(tool: TabTool): Too 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, response); + response.addError(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n')); + else if (!tool.clearsModalState && modalStates.length) + response.addError(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n')); + else + return tool.handle(tab, params, response); }, }; } diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts index c919600..4ab9571 100644 --- a/tests/cdp.spec.ts +++ b/tests/cdp.spec.ts @@ -25,7 +25,9 @@ test('cdp server', async ({ cdpServer, startClient, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); }); test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => { @@ -41,18 +43,21 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => { element: 'Hello, world!', ref: 'f0', }, - })).toHaveTextContent(`Error: No open pages available. Use the \"browser_navigate\" tool to navigate to a page first.`); + })).toHaveResponse({ + result: `Error: No open pages available. Use the "browser_navigate" tool to navigate to a page first.`, + isError: true, + }); expect(await client.callTool({ name: 'browser_snapshot', - })).toHaveTextContent(`### Page state -- Page URL: ${server.HELLO_WORLD} + })).toHaveResponse({ + pageState: expect.stringContaining(`- Page URL: ${server.HELLO_WORLD} - Page Title: Title - Page Snapshot: \`\`\`yaml - generic [active] [ref=e1]: Hello, world! -\`\`\` -`); +\`\`\``), + }); }); test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => { @@ -66,12 +71,17 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`); + })).toHaveResponse({ + result: expect.stringContaining(`Error: browserType.connectOverCDP: connect ECONNREFUSED`), + isError: true, + }); await cdpServer.start(); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); }); // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. diff --git a/tests/click.spec.ts b/tests/click.spec.ts index 4548375..d6b425e 100644 --- a/tests/click.spec.ts +++ b/tests/click.spec.ts @@ -33,21 +33,10 @@ test('browser_click', async ({ client, server, mcpBrowser }) => { element: 'Submit button', ref: 'e2', }, - })).toHaveTextContent(` -### Ran Playwright code -\`\`\`js -// Click Submit button -await page.getByRole('button', { name: 'Submit' }).click(); -\`\`\` - -### Page state -- Page URL: ${server.PREFIX} -- Page Title: Title -- Page Snapshot: -\`\`\`yaml -- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2] -\`\`\` -`); + })).toHaveResponse({ + code: `await page.getByRole('button', { name: 'Submit' }).click();`, + pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`), + }); }); test('browser_click (double)', async ({ client, server }) => { @@ -73,21 +62,10 @@ test('browser_click (double)', async ({ client, server }) => { ref: 'e2', doubleClick: true, }, - })).toHaveTextContent(` -### Ran Playwright code -\`\`\`js -// Double click Click me -await page.getByRole('heading', { name: 'Click me' }).dblclick(); -\`\`\` - -### Page state -- Page URL: ${server.PREFIX} -- Page Title: Title -- Page Snapshot: -\`\`\`yaml -- heading "Double clicked" [level=1] [ref=e3] -\`\`\` -`); + })).toHaveResponse({ + code: `await page.getByRole('heading', { name: 'Click me' }).dblclick();`, + pageState: expect.stringContaining(`- heading "Double clicked" [level=1] [ref=e3]`), + }); }); test('browser_click (right)', async ({ client, server }) => { @@ -114,6 +92,8 @@ test('browser_click (right)', async ({ client, server }) => { button: 'right', }, }); - expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`); - expect(result).toContainTextContent(`- button "Right clicked"`); + expect(result).toHaveResponse({ + code: `await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`, + pageState: expect.stringContaining(`- button "Right clicked"`), + }); }); diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 4e3a464..8dc5083 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -37,7 +37,9 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent(`Hello, world!`); + })).toHaveResponse({ + pageState: expect.stringContaining(`Hello, world!`), + }); const files = await fs.promises.readdir(config.browser!.userDataDir!); expect(files.length).toBeGreaterThan(0); @@ -58,7 +60,9 @@ test.describe(() => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: 'data:text/html,' }, - })).toContainTextContent(`Firefox`); + })).toHaveResponse({ + pageState: expect.stringContaining(`Firefox`), + }); }); }); diff --git a/tests/console.spec.ts b/tests/console.spec.ts index e5ee045..572623b 100644 --- a/tests/console.spec.ts +++ b/tests/console.spec.ts @@ -37,11 +37,10 @@ test('browser_console_messages', async ({ client, server }) => { const resource = await client.callTool({ name: 'browser_console_messages', }); - expect(resource).toHaveTextContent([ - '### Result', - `[LOG] Hello, world! @ ${server.PREFIX}:4`, - `[ERROR] Error @ ${server.PREFIX}:5`, - ].join('\n')); + expect(resource).toHaveResponse({ + result: `[LOG] Hello, world! @ ${server.PREFIX}:4 +[ERROR] Error @ ${server.PREFIX}:5`, + }); }); test('browser_console_messages (page error)', async ({ client, server }) => { @@ -64,8 +63,12 @@ test('browser_console_messages (page error)', async ({ client, server }) => { const resource = await client.callTool({ name: 'browser_console_messages', }); - expect(resource).toHaveTextContent(/Error: Error in script/); - expect(resource).toHaveTextContent(new RegExp(server.PREFIX)); + expect(resource).toHaveResponse({ + result: expect.stringContaining(`Error: Error in script`), + }); + expect(resource).toHaveResponse({ + result: expect.stringContaining(server.PREFIX), + }); }); test('recent console messages', async ({ client, server }) => { @@ -91,7 +94,7 @@ test('recent console messages', async ({ client, server }) => { }, }); - expect(response).toContainTextContent(` -### New console messages -- [LOG] Hello, world! @`); + expect(response).toHaveResponse({ + consoleMessages: expect.stringContaining(`- [LOG] Hello, world! @`), + }); }); diff --git a/tests/core.spec.ts b/tests/core.spec.ts index 4521a6b..cdf1f03 100644 --- a/tests/core.spec.ts +++ b/tests/core.spec.ts @@ -20,22 +20,15 @@ test('browser_navigate', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toHaveTextContent(` -### Ran Playwright code -\`\`\`js -// Navigate to ${server.HELLO_WORLD} -await page.goto('${server.HELLO_WORLD}'); -\`\`\` - -### Page state -- Page URL: ${server.HELLO_WORLD} + })).toHaveResponse({ + code: `await page.goto('${server.HELLO_WORLD}');`, + pageState: `- Page URL: ${server.HELLO_WORLD} - Page Title: Title - Page Snapshot: \`\`\`yaml - generic [active] [ref=e1]: Hello, world! -\`\`\` -` - ); +\`\`\``, + }); }); test('browser_select_option', async ({ client, server }) => { @@ -59,23 +52,17 @@ test('browser_select_option', async ({ client, server }) => { ref: 'e2', values: ['bar'], }, - })).toHaveTextContent(` -### Ran Playwright code -\`\`\`js -// Select options [bar] in Select -await page.getByRole('combobox').selectOption(['bar']); -\`\`\` - -### Page state -- Page URL: ${server.PREFIX} + })).toHaveResponse({ + code: `await page.getByRole('combobox').selectOption(['bar']);`, + pageState: `- Page URL: ${server.PREFIX} - Page Title: Title - Page Snapshot: \`\`\`yaml - combobox [ref=e2]: - option "Foo" - option "Bar" [selected] -\`\`\` -`); +\`\`\``, + }); }); test('browser_select_option (multiple)', async ({ client, server }) => { @@ -100,24 +87,14 @@ test('browser_select_option (multiple)', async ({ client, server }) => { ref: 'e2', values: ['bar', 'baz'], }, - })).toHaveTextContent(` -### Ran Playwright code -\`\`\`js -// Select options [bar, baz] in Select -await page.getByRole('listbox').selectOption(['bar', 'baz']); -\`\`\` - -### Page state -- Page URL: ${server.PREFIX} -- Page Title: Title -- Page Snapshot: -\`\`\`yaml + })).toHaveResponse({ + code: `await page.getByRole('listbox').selectOption(['bar', 'baz']);`, + pageState: expect.stringContaining(` - listbox [ref=e2]: - option "Foo" [ref=e3] - option "Bar" [selected] [ref=e4] - - option "Baz" [selected] [ref=e5] -\`\`\` -`); + - option "Baz" [selected] [ref=e5]`), + }); }); test('browser_resize', async ({ client, server }) => { @@ -141,12 +118,12 @@ test('browser_resize', async ({ client, server }) => { height: 780, }, }); - expect(response).toContainTextContent(`### Ran Playwright code -\`\`\`js -// Resize browser window to 390x780 -await page.setViewportSize({ width: 390, height: 780 }); -\`\`\``); - await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780'); + expect(response).toHaveResponse({ + code: `await page.setViewportSize({ width: 390, height: 780 });`, + }); + await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({ + pageState: expect.stringContaining(`Window size: 390x780`), + }); }); test('old locator error message', async ({ client, server }) => { @@ -165,10 +142,11 @@ test('old locator error message', async ({ client, server }) => { arguments: { url: server.PREFIX, }, - })).toContainTextContent(` + })).toHaveResponse({ + pageState: expect.stringContaining(` - button "Button 1" [ref=e2] - - button "Button 2" [ref=e3] - `.trim()); + - button "Button 2" [ref=e3]`), + }); await client.callTool({ name: 'browser_click', @@ -184,7 +162,10 @@ test('old locator error message', async ({ client, server }) => { element: 'Button 2', ref: 'e3', }, - })).toContainTextContent('Ref e3 not found in the current page snapshot. Try capturing new snapshot.'); + })).toHaveResponse({ + result: expect.stringContaining(`Ref e3 not found in the current page snapshot. Try capturing new snapshot.`), + isError: true, + }); }); test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => { @@ -203,5 +184,7 @@ test('visibility: hidden > visible should be shown', { annotation: { type: 'issu expect(await client.callTool({ name: 'browser_snapshot' - })).toContainTextContent('- button "Button"'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button"`), + }); }); diff --git a/tests/device.spec.ts b/tests/device.spec.ts index ab8799d..51f75c1 100644 --- a/tests/device.spec.ts +++ b/tests/device.spec.ts @@ -39,5 +39,7 @@ test('--device should work', async ({ startClient, server, mcpMode }) => { arguments: { url: server.PREFIX, }, - })).toContainTextContent(`393x659`); + })).toHaveResponse({ + pageState: expect.stringContaining(`393x659`), + }); }); diff --git a/tests/dialogs.spec.ts b/tests/dialogs.spec.ts index dada6b1..6739825 100644 --- a/tests/dialogs.spec.ts +++ b/tests/dialogs.spec.ts @@ -21,7 +21,9 @@ test('alert dialog', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- button "Button" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button" [ref=e2]`), + }); expect(await client.callTool({ name: 'browser_click', @@ -29,25 +31,31 @@ test('alert dialog', async ({ client, server }) => { element: 'Button', ref: 'e2', }, - })).toHaveTextContent(`### Ran Playwright code -\`\`\`js -// Click Button -await page.getByRole('button', { name: 'Button' }).click(); -\`\`\` + })).toHaveResponse({ + code: `await page.getByRole('button', { name: 'Button' }).click();`, + modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`, + }); -### Modal state -- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool -`); + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Button', + ref: 'e2', + }, + })).toHaveResponse({ + code: undefined, + modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`, + }); - const result = await client.callTool({ + expect(await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: true, }, + })).toHaveResponse({ + modalState: undefined, + pageState: expect.stringContaining(`- button "Button"`), }); - - expect(result).not.toContainTextContent('### Modal state'); - expect(result).toContainTextContent(`Page Snapshot:`); }); test('two alert dialogs', async ({ client, server }) => { @@ -61,7 +69,9 @@ test('two alert dialogs', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- button "Button" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button" [ref=e2]`), + }); expect(await client.callTool({ name: 'browser_click', @@ -69,15 +79,10 @@ test('two alert dialogs', async ({ client, server }) => { element: 'Button', ref: 'e2', }, - })).toHaveTextContent(`### Ran Playwright code -\`\`\`js -// Click Button -await page.getByRole('button', { name: 'Button' }).click(); -\`\`\` - -### Modal state -- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool -`); + })).toHaveResponse({ + code: `await page.getByRole('button', { name: 'Button' }).click();`, + modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`), + }); const result = await client.callTool({ name: 'browser_handle_dialog', @@ -86,9 +91,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).toHaveResponse({ + modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`), + }); const result2 = await client.callTool({ name: 'browser_handle_dialog', @@ -97,7 +102,9 @@ await page.getByRole('button', { name: 'Button' }).click(); }, }); - expect(result2).not.toContainTextContent('### Modal state'); + expect(result2).not.toHaveResponse({ + modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`), + }); }); test('confirm dialog (true)', async ({ client, server }) => { @@ -111,7 +118,9 @@ test('confirm dialog (true)', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- button "Button" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button" [ref=e2]`), + }); expect(await client.callTool({ name: 'browser_click', @@ -119,21 +128,19 @@ test('confirm dialog (true)', async ({ client, server }) => { element: 'Button', ref: 'e2', }, - })).toContainTextContent(`### Modal state -- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); + })).toHaveResponse({ + modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`), + }); - const result = await client.callTool({ + expect(await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: true, }, + })).toHaveResponse({ + modalState: undefined, + pageState: expect.stringContaining(`- generic [active] [ref=e1]: "true"`), }); - - expect(result).not.toContainTextContent('### Modal state'); - expect(result).toContainTextContent(`- Page Snapshot: -\`\`\`yaml -- generic [active] [ref=e1]: "true" -\`\`\``); }); test('confirm dialog (false)', async ({ client, server }) => { @@ -147,7 +154,9 @@ test('confirm dialog (false)', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- button "Button" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button" [ref=e2]`), + }); expect(await client.callTool({ name: 'browser_click', @@ -155,21 +164,19 @@ test('confirm dialog (false)', async ({ client, server }) => { element: 'Button', ref: 'e2', }, - })).toContainTextContent(`### Modal state -- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool -`); + })).toHaveResponse({ + modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`), + }); - const result = await client.callTool({ + expect(await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: false, }, + })).toHaveResponse({ + modalState: undefined, + pageState: expect.stringContaining(`- generic [active] [ref=e1]: "false"`), }); - - expect(result).toContainTextContent(`- Page Snapshot: -\`\`\`yaml -- generic [active] [ref=e1]: "false" -\`\`\``); }); test('prompt dialog', async ({ client, server }) => { @@ -183,7 +190,9 @@ test('prompt dialog', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- button "Button" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button" [ref=e2]`), + }); expect(await client.callTool({ name: 'browser_click', @@ -191,9 +200,9 @@ test('prompt dialog', async ({ client, server }) => { element: 'Button', ref: 'e2', }, - })).toContainTextContent(`### Modal state -- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool -`); + })).toHaveResponse({ + modalState: expect.stringContaining(`- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`), + }); const result = await client.callTool({ name: 'browser_handle_dialog', @@ -203,10 +212,9 @@ test('prompt dialog', async ({ client, server }) => { }, }); - expect(result).toContainTextContent(`- Page Snapshot: -\`\`\`yaml -- generic [active] [ref=e1]: Answer -\`\`\``); + expect(result).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Answer`), + }); }); test('alert dialog w/ race', async ({ client, server }) => { @@ -214,7 +222,9 @@ test('alert dialog w/ race', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- button "Button" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- button "Button" [ref=e2]`), + }); expect(await client.callTool({ name: 'browser_click', @@ -222,15 +232,10 @@ test('alert dialog w/ race', async ({ client, server }) => { element: 'Button', ref: 'e2', }, - })).toHaveTextContent(`### Ran Playwright code -\`\`\`js -// Click Button -await page.getByRole('button', { name: 'Button' }).click(); -\`\`\` - -### Modal state -- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool -`); + })).toHaveResponse({ + code: `await page.getByRole('button', { name: 'Button' }).click();`, + modalState: expect.stringContaining(`- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`), + }); const result = await client.callTool({ name: 'browser_handle_dialog', @@ -239,11 +244,12 @@ await page.getByRole('button', { name: 'Button' }).click(); }, }); - expect(result).not.toContainTextContent('### Modal state'); - expect(result).toContainTextContent(`### Page state -- Page URL: ${server.PREFIX} + expect(result).toHaveResponse({ + modalState: undefined, + pageState: expect.stringContaining(`- Page URL: ${server.PREFIX} - Page Title: - Page Snapshot: \`\`\`yaml -- button "Button"`); +- button "Button"`), + }); }); diff --git a/tests/evaluate.spec.ts b/tests/evaluate.spec.ts index f1355a6..045c74f 100644 --- a/tests/evaluate.spec.ts +++ b/tests/evaluate.spec.ts @@ -20,15 +20,19 @@ test('browser_evaluate', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`- Page Title: Title`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- Page Title: Title`), + }); - const result = await client.callTool({ + expect(await client.callTool({ name: 'browser_evaluate', arguments: { function: '() => document.title', }, + })).toHaveResponse({ + result: `"Title"`, + code: `await page.evaluate('() => document.title');`, }); - expect(result).toContainTextContent(`"Title"`); }); test('browser_evaluate (element)', async ({ client, server }) => { @@ -47,15 +51,19 @@ test('browser_evaluate (element)', async ({ client, server }) => { element: 'body', ref: 'e1', }, - })).toContainTextContent(`### Result -"red"`); + })).toHaveResponse({ + result: `"red"`, + code: `await page.getByText('Hello, world!').evaluate('element => element.style.backgroundColor');`, + }); }); test('browser_evaluate (error)', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`- Page Title: Title`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- Page Title: Title`), + }); const result = await client.callTool({ name: 'browser_evaluate', diff --git a/tests/files.spec.ts b/tests/files.spec.ts index bf43795..160686a 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -26,22 +26,21 @@ test('browser_file_upload', async ({ client, server }, testInfo) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent(` -\`\`\`yaml -- generic [active] [ref=e1]: + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: - button "Choose File" [ref=e2] - - button "Button" [ref=e3] -\`\`\``); + - button "Button" [ref=e3]`), + }); { expect(await client.callTool({ name: 'browser_file_upload', arguments: { paths: [] }, - })).toHaveTextContent(` -Error: The tool "browser_file_upload" can only be used when there is related modal state present. -### Modal state -- There is no modal state present - `.trim()); + })).toHaveResponse({ + isError: true, + result: expect.stringContaining(`The tool "browser_file_upload" can only be used when there is related modal state present.`), + modalState: expect.stringContaining(`- There is no modal state present`), + }); } expect(await client.callTool({ @@ -50,8 +49,9 @@ Error: The tool "browser_file_upload" can only be used when there is related mod element: 'Textbox', ref: 'e2', }, - })).toContainTextContent(`### Modal state -- [File chooser]: can be handled by the "browser_file_upload" tool`); + })).toHaveResponse({ + modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`), + }); const filePath = testInfo.outputPath('test.txt'); await fs.writeFile(filePath, 'Hello, world!'); @@ -64,7 +64,10 @@ Error: The tool "browser_file_upload" can only be used when there is related mod }, }); - expect(response).not.toContainTextContent('### Modal state'); + expect(response).toHaveResponse({ + code: expect.stringContaining(`await fileChooser.setFiles(`), + modalState: undefined, + }); } { @@ -76,7 +79,9 @@ Error: The tool "browser_file_upload" can only be used when there is related mod }, }); - expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool'); + expect(response).toHaveResponse({ + modalState: `- [File chooser]: can be handled by the "browser_file_upload" tool`, + }); } { @@ -88,9 +93,10 @@ Error: The tool "browser_file_upload" can only be used when there is related mod }, }); - expect(response).toContainTextContent(`Error: Tool "browser_click" does not handle the modal state. -### Modal state -- [File chooser]: can be handled by the "browser_file_upload" tool`); + expect(response).toHaveResponse({ + result: `Error: Tool "browser_click" does not handle the modal state.`, + modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`), + }); } }); @@ -105,7 +111,9 @@ test('clicking on download link emits download', async ({ startClient, server, m expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent('- link "Download" [ref=e2]'); + })).toHaveResponse({ + pageState: expect.stringContaining(`- link "Download" [ref=e2]`), + }); await client.callTool({ name: 'browser_click', arguments: { @@ -113,8 +121,9 @@ test('clicking on download link emits download', async ({ startClient, server, m ref: 'e2', }, }); - await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads -- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); + await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({ + downloads: `- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`, + }); }); test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => { @@ -136,5 +145,7 @@ test('navigating to download link emits download', async ({ startClient, server, arguments: { url: server.PREFIX + 'download', }, - })).toContainTextContent('### Downloads'); + })).toHaveResponse({ + downloads: expect.stringContaining(`- Downloaded file test.txt to`), + }); }); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index e4f23c4..783c91e 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -199,41 +199,14 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): type Response = Awaited>; export const expect = baseExpect.extend({ - toHaveTextContent(response: Response, content: string | RegExp) { + toHaveResponse(response: Response, object: any) { + const parsed = parseResponse(response); const isNot = this.isNot; try { - const text = (response.content as any)[0].text; - if (typeof content === 'string') { - if (isNot) - baseExpect(text.trim()).not.toBe(content.trim()); - else - baseExpect(text.trim()).toBe(content.trim()); - } else { - if (isNot) - baseExpect(text).not.toMatch(content); - else - baseExpect(text).toMatch(content); - } - } catch (e) { - return { - pass: isNot, - message: () => e.message, - }; - } - return { - pass: !isNot, - message: () => ``, - }; - }, - - toContainTextContent(response: Response, content: string) { - const isNot = this.isNot; - try { - const texts = (response.content as any).map(c => c.text).join('\n'); if (isNot) - expect(texts).not.toContain(content); + expect(parsed).not.toEqual(expect.objectContaining(object)); else - expect(texts).toContain(content); + expect(parsed).toEqual(expect.objectContaining(object)); } catch (e) { return { pass: isNot, @@ -250,3 +223,46 @@ export const expect = baseExpect.extend({ export function formatOutput(output: string): string[] { return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean); } + +function parseResponse(response: any) { + const text = (response as any).content[0].text; + const sections = parseSections(text); + + const result = sections.get('Result'); + const code = sections.get('Ran Playwright code'); + const tabs = sections.get('Open tabs'); + const pageState = sections.get('Page state'); + const consoleMessages = sections.get('New console messages'); + const modalState = sections.get('Modal state'); + const downloads = sections.get('Downloads'); + const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, ''); + const isError = response.isError; + + return { + result, + code: codeNoFrame, + tabs, + pageState, + consoleMessages, + modalState, + downloads, + isError, + }; +} + +function parseSections(text: string): Map { + const sections = new Map(); + const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element + + for (const section of sectionHeaders) { + const firstNewlineIndex = section.indexOf('\n'); + if (firstNewlineIndex === -1) + continue; + + const sectionName = section.substring(0, firstNewlineIndex); + const sectionContent = section.substring(firstNewlineIndex + 1).trim(); + sections.set(sectionName, sectionContent); + } + + return sections; +} diff --git a/tests/headed.spec.ts b/tests/headed.spec.ts index 69202c4..7274657 100644 --- a/tests/headed.spec.ts +++ b/tests/headed.spec.ts @@ -21,6 +21,7 @@ for (const mcpHeadless of [false, true]) { test.use({ mcpHeadless }); test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux'); test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker'); + test('browser', async ({ client, server, mcpBrowser }) => { test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test'); server.route('/', (req, res) => { @@ -40,11 +41,9 @@ for (const mcpHeadless of [false, true]) { }, }); - expect(response).toContainTextContent(`Mozilla/5.0`); - if (mcpHeadless) - expect(response).toContainTextContent(`HeadlessChrome`); - else - expect(response).not.toContainTextContent(`HeadlessChrome`); + expect(response).toHaveResponse({ + pageState: (mcpHeadless ? expect : expect.not).stringContaining(`HeadlessChrome`), + }); }); }); } diff --git a/tests/iframes.spec.ts b/tests/iframes.spec.ts index 49a79b3..69d9863 100644 --- a/tests/iframes.spec.ts +++ b/tests/iframes.spec.ts @@ -22,9 +22,8 @@ test('stitched aria frames', async ({ client }) => { arguments: { url: `data:text/html,

Hello

`, }, - })).toContainTextContent(` -\`\`\`yaml -- generic [active] [ref=e1]: + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: - heading "Hello" [level=1] [ref=e2] - iframe [ref=e3]: - generic [active] [ref=f1e1]: @@ -32,7 +31,8 @@ test('stitched aria frames', async ({ client }) => { - main [ref=f1e3]: - iframe [ref=f1e4]: - paragraph [ref=f2e2]: Nested -\`\`\``); +\`\`\``), + }); expect(await client.callTool({ name: 'browser_click', @@ -40,5 +40,7 @@ test('stitched aria frames', async ({ client }) => { element: 'World', ref: 'f1e2', }, - })).toContainTextContent(`// Click World`); + })).toHaveResponse({ + code: `await page.locator('iframe').first().contentFrame().getByRole('button', { name: 'World' }).click();`, + }); }); diff --git a/tests/install.spec.ts b/tests/install.spec.ts index a7cd1a3..e8c1b42 100644 --- a/tests/install.spec.ts +++ b/tests/install.spec.ts @@ -20,5 +20,7 @@ test('browser_install', async ({ client, mcpBrowser }) => { test.skip(mcpBrowser !== 'chromium', 'Test only chromium'); expect(await client.callTool({ name: 'browser_install', - })).toContainTextContent(`### No open tabs`); + })).toHaveResponse({ + tabs: expect.stringContaining(`No open tabs`), + }); }); diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index 25cf2b2..37fa264 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -27,18 +27,17 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => { expect(await client.callTool({ name: 'browser_close', - })).toContainTextContent(`### Ran Playwright code -\`\`\`js -await page.close() -\`\`\` - -### No open tabs -Use the "browser_navigate" tool to navigate to a page first.`); + })).toHaveResponse({ + code: `await page.close()`, + tabs: `No open tabs. Use the "browser_navigate" tool to navigate to a page first.`, + }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); await client.close(); @@ -68,7 +67,10 @@ test('executable path', async ({ startClient, server }) => { name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); - expect(response).toContainTextContent(`executable doesn't exist`); + expect(response).toHaveResponse({ + result: expect.stringContaining(`executable doesn't exist`), + isError: true, + }); }); test('persistent context', async ({ startClient, server }) => { @@ -82,11 +84,12 @@ test('persistent context', async ({ startClient, server }) => { `, 'text/html'); const { client } = await startClient(); - const response = await client.callTool({ + expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Storage: NO`), }); - expect(response).toContainTextContent(`Storage: NO`); await new Promise(resolve => setTimeout(resolve, 3000)); @@ -95,12 +98,12 @@ test('persistent context', async ({ startClient, server }) => { }); const { client: client2 } = await startClient(); - const response2 = await client2.callTool({ + expect(await client2.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Storage: YES`), }); - - expect(response2).toContainTextContent(`Storage: YES`); }); test('isolated context', async ({ startClient, server }) => { @@ -114,22 +117,24 @@ test('isolated context', async ({ startClient, server }) => { `, 'text/html'); const { client: client1 } = await startClient({ args: [`--isolated`] }); - const response = await client1.callTool({ + expect(await client1.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Storage: NO`), }); - expect(response).toContainTextContent(`Storage: NO`); await client1.callTool({ name: 'browser_close', }); const { client: client2 } = await startClient({ args: [`--isolated`] }); - const response2 = await client2.callTool({ + expect(await client2.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Storage: NO`), }); - expect(response2).toContainTextContent(`Storage: NO`); }); test('isolated context with storage state', async ({ startClient, server }, testInfo) => { @@ -155,9 +160,10 @@ test('isolated context with storage state', async ({ startClient, server }, test `--isolated`, `--storage-state=${storageStatePath}`, ] }); - const response = await client.callTool({ + expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, + })).toHaveResponse({ + pageState: expect.stringContaining(`Storage: session-value`), }); - expect(response).toContainTextContent(`Storage: session-value`); }); diff --git a/tests/network.spec.ts b/tests/network.spec.ts index a8eb492..96ee98c 100644 --- a/tests/network.spec.ts +++ b/tests/network.spec.ts @@ -40,7 +40,8 @@ test('browser_network_requests', async ({ client, server }) => { await expect.poll(() => client.callTool({ name: 'browser_network_requests', - })).toHaveTextContent(`### Result -[GET] ${`${server.PREFIX}`} => [200] OK -[GET] ${`${server.PREFIX}json`} => [200] OK`); + })).toHaveResponse({ + result: expect.stringContaining(`[GET] ${`${server.PREFIX}`} => [200] OK +[GET] ${`${server.PREFIX}json`} => [200] OK`), + }); }); diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts index aa65020..3d72e75 100644 --- a/tests/pdf.spec.ts +++ b/tests/pdf.spec.ts @@ -27,7 +27,10 @@ test('save as pdf unavailable', async ({ startClient, server }) => { expect(await client.callTool({ name: 'browser_pdf_save', - })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); + })).toHaveResponse({ + result: 'Error: Tool "browser_pdf_save" not found', + isError: true, + }); }); test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { @@ -40,12 +43,16 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); - - const response = await client.callTool({ - name: 'browser_pdf_save', + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + expect(await client.callTool({ + name: 'browser_pdf_save', + })).toHaveResponse({ + code: expect.stringContaining(`await page.pdf(`), + result: expect.stringMatching(/Saved page as.*page-[^:]+.pdf/), }); - expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/); }); test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => { @@ -58,14 +65,19 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); expect(await client.callTool({ name: 'browser_pdf_save', arguments: { filename: 'output.pdf', }, - })).toContainTextContent(`output.pdf`); + })).toHaveResponse({ + result: expect.stringContaining(`output.pdf`), + code: expect.stringContaining(`await page.pdf(`), + }); const files = [...fs.readdirSync(outputDir)]; diff --git a/tests/screenshot.spec.ts b/tests/screenshot.spec.ts index e329a9f..f1daa3b 100644 --- a/tests/screenshot.spec.ts +++ b/tests/screenshot.spec.ts @@ -25,7 +25,9 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); expect(await client.callTool({ name: 'browser_take_screenshot', @@ -51,7 +53,9 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`[ref=e1]`); + })).toHaveResponse({ + pageState: expect.stringContaining(`[ref=e1]`), + }); expect(await client.callTool({ name: 'browser_take_screenshot', @@ -82,7 +86,9 @@ test('--output-dir should work', async ({ startClient, server }, testInfo) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); await client.callTool({ name: 'browser_take_screenshot', @@ -104,7 +110,9 @@ for (const raw of [undefined, true]) { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); expect(await client.callTool({ name: 'browser_take_screenshot', @@ -144,7 +152,9 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); expect(await client.callTool({ name: 'browser_take_screenshot', @@ -184,7 +194,9 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); await client.callTool({ name: 'browser_take_screenshot', @@ -209,7 +221,9 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server }, expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); expect(await client.callTool({ name: 'browser_take_screenshot', @@ -236,7 +250,9 @@ test('browser_take_screenshot (fullPage with element should error)', async ({ st expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`[ref=e1]`); + })).toHaveResponse({ + pageState: expect.stringContaining(`[ref=e1]`), + }); const result = await client.callTool({ name: 'browser_take_screenshot', @@ -259,7 +275,9 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient // Ensure we have a tab but don't navigate anywhere (no snapshot captured) expect(await client.callTool({ name: 'browser_tab_list', - })).toContainTextContent('about:blank'); + })).toHaveResponse({ + tabs: `- 0: (current) [] (about:blank)`, + }); // This should work without requiring a snapshot since it's a viewport screenshot expect(await client.callTool({ diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts index 6f5dced..58b7bc0 100644 --- a/tests/tabs.spec.ts +++ b/tests/tabs.spec.ts @@ -30,99 +30,87 @@ async function createTab(client: Client, title: string, body: string) { test('list initial tabs', async ({ client }) => { expect(await client.callTool({ name: 'browser_tab_list', - })).toHaveTextContent(`### Open tabs -- 0: (current) [] (about:blank)`); + })).toHaveResponse({ + tabs: `- 0: (current) [] (about:blank)`, + }); }); test('list first tab', async ({ client }) => { await createTab(client, 'Tab one', 'Body one'); expect(await client.callTool({ name: 'browser_tab_list', - })).toHaveTextContent(`### Open tabs -- 0: [] (about:blank) -- 1: (current) [Tab one] (data:text/html,Tab oneBody one)`); + })).toHaveResponse({ + tabs: `- 0: [] (about:blank) +- 1: (current) [Tab one] (data:text/html,Tab oneBody one)`, + }); }); test('create new tab', async ({ client }) => { - 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 + expect(await createTab(client, 'Tab one', 'Body one')).toHaveResponse({ + tabs: `- 0: [] (about:blank) +- 1: (current) [Tab one] (data:text/html,Tab oneBody one)`, + pageState: expect.stringContaining(`- Page URL: data:text/html,Tab oneBody one - Page Title: Tab one - Page Snapshot: \`\`\`yaml - generic [active] [ref=e1]: Body one -\`\`\``); +\`\`\``), + }); - const result2 = await createTab(client, 'Tab two', 'Body two'); - expect(result2).toContainTextContent(`### Open tabs -- 0: [] (about:blank) + expect(await createTab(client, 'Tab two', 'Body two')).toHaveResponse({ + 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 +- 2: (current) [Tab two] (data:text/html,Tab twoBody two)`, + pageState: expect.stringContaining(`- Page URL: data:text/html,Tab twoBody two - Page Title: Tab two - Page Snapshot: \`\`\`yaml - generic [active] [ref=e1]: Body two -\`\`\``); +\`\`\``), + }); }); test('select tab', async ({ client }) => { await createTab(client, 'Tab one', 'Body one'); await createTab(client, 'Tab two', 'Body two'); - const result = await client.callTool({ + expect(await client.callTool({ name: 'browser_tab_select', arguments: { index: 1, }, - }); - expect(result).toContainTextContent(`### Open tabs -- 0: [] (about:blank) + })).toHaveResponse({ + tabs: `- 0: [] (about:blank) - 1: (current) [Tab one] (data:text/html,Tab oneBody one) -- 2: [Tab two] (data:text/html,Tab twoBody two)`); - - expect(result).toContainTextContent(` -### Page state -- Page URL: data:text/html,Tab oneBody one +- 2: [Tab two] (data:text/html,Tab twoBody two)`, + pageState: expect.stringContaining(`- Page URL: data:text/html,Tab oneBody one - Page Title: Tab one - Page Snapshot: \`\`\`yaml - generic [active] [ref=e1]: Body one -\`\`\``); +\`\`\``), + }); }); test('close tab', async ({ client }) => { await createTab(client, 'Tab one', 'Body one'); await createTab(client, 'Tab two', 'Body two'); - const result = await client.callTool({ + expect(await client.callTool({ name: 'browser_tab_close', arguments: { index: 2, }, - }); - 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 + })).toHaveResponse({ + tabs: `- 0: [] (about:blank) +- 1: (current) [Tab one] (data:text/html,Tab oneBody one)`, + pageState: expect.stringContaining(`- Page URL: data:text/html,Tab oneBody one - Page Title: Tab one - Page Snapshot: \`\`\`yaml - generic [active] [ref=e1]: Body one -\`\`\``); +\`\`\``), + }); }); test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => { diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts index ba4657d..5cfe3e0 100644 --- a/tests/trace.spec.ts +++ b/tests/trace.spec.ts @@ -29,7 +29,9 @@ test('check that trace is saved', async ({ startClient, server, mcpMode }, testI expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent(`Navigate to http://localhost`); + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy(); }); diff --git a/tests/type.spec.ts b/tests/type.spec.ts index 08b2007..ffcc6c6 100644 --- a/tests/type.spec.ts +++ b/tests/type.spec.ts @@ -41,13 +41,18 @@ test('browser_type', async ({ client, server }) => { submit: true, }, }); - expect(response).toContainTextContent(`fill('Hi!');`); - expect(response).toContainTextContent(`- textbox`); + expect(response).toHaveResponse({ + code: `await page.getByRole('textbox').fill('Hi!'); +await page.getByRole('textbox').press('Enter');`, + pageState: expect.stringContaining(`- textbox`), + }); } expect(await client.callTool({ name: 'browser_console_messages', - })).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/); + })).toHaveResponse({ + result: expect.stringContaining(`[LOG] Key pressed: Enter , Text: Hi!`), + }); }); test('browser_type (slowly)', async ({ client, server }) => { @@ -72,15 +77,23 @@ test('browser_type (slowly)', async ({ client, server }) => { }, }); - expect(response).toContainTextContent(`pressSequentially('Hi!');`); - expect(response).toContainTextContent(`- textbox`); + expect(response).toHaveResponse({ + code: `await page.getByRole('textbox').pressSequentially('Hi!');`, + pageState: expect.stringContaining(`- textbox`), + }); } const response = await client.callTool({ name: 'browser_console_messages', }); - expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /); - expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/); - expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/); + expect(response).toHaveResponse({ + result: expect.stringContaining(`[LOG] Key pressed: H Text: `), + }); + expect(response).toHaveResponse({ + result: expect.stringContaining(`[LOG] Key pressed: i Text: H`), + }); + expect(response).toHaveResponse({ + result: expect.stringContaining(`[LOG] Key pressed: ! Text: Hi`), + }); }); test('browser_type (no submit)', async ({ client, server }) => { @@ -95,7 +108,9 @@ test('browser_type (no submit)', async ({ client, server }) => { url: server.PREFIX, }, }); - expect(response).toContainTextContent(`- textbox`); + expect(response).toHaveResponse({ + pageState: expect.stringContaining(`- textbox`), + }); } { const response = await client.callTool({ @@ -106,14 +121,18 @@ test('browser_type (no submit)', async ({ client, server }) => { text: 'Hi!', }, }); - expect(response).toContainTextContent(`fill('Hi!');`); - // Should yield no snapshot. - expect(response).not.toContainTextContent(`- textbox`); + expect(response).toHaveResponse({ + code: expect.stringContaining(`fill('Hi!')`), + // Should yield no snapshot. + pageState: expect.not.stringContaining(`- textbox`), + }); } { const response = await client.callTool({ name: 'browser_console_messages', }); - expect(response).toHaveTextContent(/\[LOG\] New value: Hi!/); + expect(response).toHaveResponse({ + result: expect.stringContaining(`[LOG] New value: Hi!`), + }); } }); diff --git a/tests/wait.spec.ts b/tests/wait.spec.ts index d29a09a..fcc38fe 100644 --- a/tests/wait.spec.ts +++ b/tests/wait.spec.ts @@ -47,7 +47,9 @@ test('browser_wait_for(text)', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_wait_for', arguments: { text: 'Text to appear' }, - })).toContainTextContent(`- generic [ref=e3]: Text to appear`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`), + }); }); test('browser_wait_for(textGone)', async ({ client, server }) => { @@ -81,5 +83,7 @@ test('browser_wait_for(textGone)', async ({ client, server }) => { expect(await client.callTool({ name: 'browser_wait_for', arguments: { textGone: 'Text to disappear' }, - })).toContainTextContent(`- generic [ref=e3]: Text to appear`); + })).toHaveResponse({ + pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`), + }); }); diff --git a/tests/webdriver.spec.ts b/tests/webdriver.spec.ts index faf5fdc..bd96925 100644 --- a/tests/webdriver.spec.ts +++ b/tests/webdriver.spec.ts @@ -34,5 +34,7 @@ test('do not falsely advertise user agent as a test driver', async ({ client, se arguments: { url: server.PREFIX, }, - })).toContainTextContent('webdriver: false'); + })).toHaveResponse({ + pageState: expect.stringContaining(`webdriver: false`), + }); });