diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 50b4c02c5..17151961f 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,6 +1,6 @@ -# Chrome DevTools MCP Tool Reference (~6940 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~8190 cl100k_base tokens) - **[Input automation](#input-automation)** (9 tools) - [`click`](#click) @@ -193,7 +193,7 @@ ### `select_page` -**Description:** Select a page as a context for future tool calls. +**Description:** Select a page as a context for future tool calls. For multi-agent workflows, prefer passing pageId directly to each tool instead of using [`select_page`](#select_page). **Parameters:** @@ -333,6 +333,7 @@ so returned values have to be JSON-serializable. }` - **args** (array) _(optional)_: An optional list of arguments to pass to the function. +- **pageId** (number) _(optional)_: Targets a specific page by ID. Use [`list_pages`](#list_pages) to get available page IDs. If omitted, operates on the most recently selected page. --- diff --git a/src/McpContext.ts b/src/McpContext.ts index 3ebbeae95..e2f0703ce 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -851,9 +851,11 @@ export class McpContext implements Context { waitForEventsAfterAction( action: () => Promise, - options?: {timeout?: number}, + options?: {timeout?: number; page?: McpPage}, ): Promise { - const page = this.#getSelectedMcpPage(); + const page = options?.page + ? this.#getMcpPage(options.page.pptrPage) + : this.#getSelectedMcpPage(); const cpuMultiplier = page.cpuThrottlingRate; const networkMultiplier = getNetworkMultiplierFromString( page.networkConditions, diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index b4792629c..7ce8cb187 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -150,7 +150,7 @@ export const cliOptions = { experimentalPageIdRouting: { type: 'boolean', describe: - 'Whether to expose pageId on page-scoped tools and route requests by page ID.', + '(Deprecated, now always enabled) pageId is always exposed on page-scoped tools.', hidden: true, }, experimentalDevtools: { diff --git a/src/bin/cliDefinitions.ts b/src/bin/cliDefinitions.ts index d32705617..619e3c781 100644 --- a/src/bin/cliDefinitions.ts +++ b/src/bin/cliDefinitions.ts @@ -47,6 +47,13 @@ export const commands: Commands = { 'Whether to include a snapshot in the response. Default is false.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, close_page: { @@ -86,6 +93,13 @@ export const commands: Commands = { 'Whether to include a snapshot in the response. Default is false.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, emulate: { @@ -135,6 +149,13 @@ export const commands: Commands = { "Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.", required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, evaluate_script: { @@ -155,6 +176,13 @@ export const commands: Commands = { description: 'An optional list of arguments to pass to the function.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, fill: { @@ -182,6 +210,13 @@ export const commands: Commands = { 'Whether to include a snapshot in the response. Default is false.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, get_console_message: { @@ -196,6 +231,13 @@ export const commands: Commands = { 'The msgid of a console message on the page from the listed console messages', required: true, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, get_network_request: { @@ -224,6 +266,13 @@ export const commands: Commands = { 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, handle_dialog: { @@ -244,6 +293,13 @@ export const commands: Commands = { description: 'Optional prompt text to enter into the dialog.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, hover: { @@ -264,6 +320,13 @@ export const commands: Commands = { 'Whether to include a snapshot in the response. Default is false.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, lighthouse_audit: { @@ -294,6 +357,13 @@ export const commands: Commands = { description: 'Directory for reports. If omitted, uses temporary files.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, list_console_messages: { @@ -330,6 +400,13 @@ export const commands: Commands = { required: false, default: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, list_network_requests: { @@ -366,12 +443,27 @@ export const commands: Commands = { required: false, default: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, list_pages: { description: 'Get a list of pages open in the browser.', category: 'Navigation automation', - args: {}, + args: { + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, + }, }, navigate_page: { description: @@ -420,6 +512,13 @@ export const commands: Commands = { 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, new_page: { @@ -475,6 +574,13 @@ export const commands: Commands = { 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"', required: true, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, performance_start_trace: { @@ -505,6 +611,13 @@ export const commands: Commands = { 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, performance_stop_trace: { @@ -519,6 +632,13 @@ export const commands: Commands = { 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, press_key: { @@ -540,6 +660,13 @@ export const commands: Commands = { 'Whether to include a snapshot in the response. Default is false.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, resize_page: { @@ -559,10 +686,18 @@ export const commands: Commands = { description: 'Page height', required: true, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, select_page: { - description: 'Select a page as a context for future tool calls.', + description: + 'Select a page as a context for future tool calls. For multi-agent workflows, prefer passing pageId directly to each tool instead of using select_page.', category: 'Navigation automation', args: { pageId: { @@ -592,6 +727,13 @@ export const commands: Commands = { 'A path to a .heapsnapshot file to save the heapsnapshot to.', required: true, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, take_screenshot: { @@ -635,6 +777,13 @@ export const commands: Commands = { 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, take_snapshot: { @@ -656,6 +805,13 @@ export const commands: Commands = { 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, type_text: { @@ -675,6 +831,13 @@ export const commands: Commands = { 'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, upload_file: { @@ -701,6 +864,13 @@ export const commands: Commands = { 'Whether to include a snapshot in the response. Default is false.', required: false, }, + pageId: { + name: 'pageId', + type: 'number', + description: + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + required: false, + }, }, }, } as const; diff --git a/src/index.ts b/src/index.ts index 2689e34a6..1ad5186a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,10 +159,7 @@ export async function createMcpServer( return; } const schema = - 'pageScoped' in tool && - tool.pageScoped && - serverArgs.experimentalPageIdRouting && - !serverArgs.slim + 'pageScoped' in tool && tool.pageScoped && !serverArgs.slim ? {...tool.schema, ...pageIdSchema} : tool.schema; @@ -187,9 +184,7 @@ export async function createMcpServer( : new McpResponse(serverArgs); if ('pageScoped' in tool && tool.pageScoped) { const page = - serverArgs.experimentalPageIdRouting && - params.pageId && - !serverArgs.slim + params.pageId && !serverArgs.slim ? context.getPageById(params.pageId) : context.getSelectedMcpPage(); response.setPage(page); diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 170198803..842949768 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -169,7 +169,7 @@ export type Context = Readonly<{ ): Promise<{filename: string}>; waitForEventsAfterAction( action: () => Promise, - options?: {timeout?: number}, + options?: {timeout?: number; page?: ContextPage}, ): Promise; waitForTextOnPage( text: string[], @@ -296,7 +296,12 @@ export const CLOSE_PAGE_ERROR = 'The last open page cannot be closed. It is fine to keep it open.'; export const pageIdSchema = { - pageId: zod.number().optional().describe('Targets a specific page by ID.'), + pageId: zod + .number() + .optional() + .describe( + 'Targets a specific page by ID. Use list_pages to get available page IDs. If omitted, operates on the most recently selected page.', + ), }; export const timeoutSchema = { diff --git a/src/tools/input.ts b/src/tools/input.ts index 492d55378..ec9d18ed7 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -62,11 +62,14 @@ export const click = definePageTool({ const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); try { - await context.waitForEventsAfterAction(async () => { - await handle.asLocator().click({ - count: request.params.dblClick ? 2 : 1, - }); - }); + await context.waitForEventsAfterAction( + async () => { + await handle.asLocator().click({ + count: request.params.dblClick ? 2 : 1, + }); + }, + {page: request.page}, + ); response.appendResponseLine( request.params.dblClick ? `Successfully double clicked on the element` @@ -99,11 +102,14 @@ export const clickAt = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - await context.waitForEventsAfterAction(async () => { - await page.pptrPage.mouse.click(request.params.x, request.params.y, { - clickCount: request.params.dblClick ? 2 : 1, - }); - }); + await context.waitForEventsAfterAction( + async () => { + await page.pptrPage.mouse.click(request.params.x, request.params.y, { + clickCount: request.params.dblClick ? 2 : 1, + }); + }, + {page}, + ); response.appendResponseLine( request.params.dblClick ? `Successfully double clicked at the coordinates` @@ -134,9 +140,12 @@ export const hover = definePageTool({ const uid = request.params.uid; const handle = await request.page.getElementByUid(uid); try { - await context.waitForEventsAfterAction(async () => { - await handle.asLocator().hover(); - }); + await context.waitForEventsAfterAction( + async () => { + await handle.asLocator().hover(); + }, + {page: request.page}, + ); response.appendResponseLine(`Successfully hovered over the element`); if (request.params.includeSnapshot) { response.includeSnapshot(); @@ -235,14 +244,17 @@ export const fill = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - await context.waitForEventsAfterAction(async () => { - await fillFormElement( - request.params.uid, - request.params.value, - context as McpContext, - page, - ); - }); + await context.waitForEventsAfterAction( + async () => { + await fillFormElement( + request.params.uid, + request.params.value, + context as McpContext, + page, + ); + }, + {page}, + ); response.appendResponseLine(`Successfully filled out the element`); if (request.params.includeSnapshot) { response.includeSnapshot(); @@ -263,14 +275,17 @@ export const typeText = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - await context.waitForEventsAfterAction(async () => { - await page.pptrPage.keyboard.type(request.params.text); - if (request.params.submitKey) { - await page.pptrPage.keyboard.press( - request.params.submitKey as KeyInput, - ); - } - }); + await context.waitForEventsAfterAction( + async () => { + await page.pptrPage.keyboard.type(request.params.text); + if (request.params.submitKey) { + await page.pptrPage.keyboard.press( + request.params.submitKey as KeyInput, + ); + } + }, + {page}, + ); response.appendResponseLine( `Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`, ); @@ -295,11 +310,14 @@ export const drag = definePageTool({ ); const toHandle = await request.page.getElementByUid(request.params.to_uid); try { - await context.waitForEventsAfterAction(async () => { - await fromHandle.drag(toHandle); - await new Promise(resolve => setTimeout(resolve, 50)); - await toHandle.drop(fromHandle); - }); + await context.waitForEventsAfterAction( + async () => { + await fromHandle.drag(toHandle); + await new Promise(resolve => setTimeout(resolve, 50)); + await toHandle.drop(fromHandle); + }, + {page: request.page}, + ); response.appendResponseLine(`Successfully dragged an element`); if (request.params.includeSnapshot) { response.includeSnapshot(); @@ -332,14 +350,17 @@ export const fillForm = definePageTool({ handler: async (request, response, context) => { const page = request.page; for (const element of request.params.elements) { - await context.waitForEventsAfterAction(async () => { - await fillFormElement( - element.uid, - element.value, - context as McpContext, - page, - ); - }); + await context.waitForEventsAfterAction( + async () => { + await fillFormElement( + element.uid, + element.value, + context as McpContext, + page, + ); + }, + {page}, + ); } response.appendResponseLine(`Successfully filled out the form`); if (request.params.includeSnapshot) { @@ -418,15 +439,18 @@ export const pressKey = definePageTool({ const tokens = parseKey(request.params.key); const [key, ...modifiers] = tokens; - await context.waitForEventsAfterAction(async () => { - for (const modifier of modifiers) { - await page.pptrPage.keyboard.down(modifier); - } - await page.pptrPage.keyboard.press(key); - for (const modifier of modifiers.toReversed()) { - await page.pptrPage.keyboard.up(modifier); - } - }); + await context.waitForEventsAfterAction( + async () => { + for (const modifier of modifiers) { + await page.pptrPage.keyboard.down(modifier); + } + await page.pptrPage.keyboard.press(key); + for (const modifier of modifiers.toReversed()) { + await page.pptrPage.keyboard.up(modifier); + } + }, + {page}, + ); response.appendResponseLine( `Successfully pressed key: ${request.params.key}`, diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 895632bd3..2e1fda6a2 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -33,7 +33,7 @@ export const listPages = definePageTool(args => { export const selectPage = defineTool({ name: 'select_page', - description: `Select a page as a context for future tool calls.`, + description: `Select a page as a context for future tool calls. For multi-agent workflows, prefer passing pageId directly to each tool instead of using select_page.`, annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: true, @@ -122,7 +122,7 @@ export const newPage = defineTool({ timeout: request.params.timeout, }); }, - {timeout: request.params.timeout}, + {timeout: request.params.timeout, page}, ); response.setIncludePages(true); @@ -261,7 +261,7 @@ export const navigatePage = definePageTool({ break; } }, - {timeout: request.params.timeout}, + {timeout: request.params.timeout, page}, ); } finally { page.pptrPage.off('dialog', dialogHandler); diff --git a/src/tools/script.ts b/src/tools/script.ts index e3ff14d7d..63ae17f5b 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -9,7 +9,7 @@ import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; import type {ExtensionServiceWorker} from '../types.js'; import {ToolCategory} from './categories.js'; -import type {Context, Response} from './ToolDefinition.js'; +import type {Context, ContextPage, Response} from './ToolDefinition.js'; import {defineTool, pageIdSchema} from './ToolDefinition.js'; export type Evaluatable = Page | Frame | WebWorker; @@ -46,7 +46,7 @@ Example with arguments: \`(el) => { ) .optional() .describe(`An optional list of arguments to pass to the function.`), - ...(cliArgs?.experimentalPageIdRouting ? pageIdSchema : {}), + ...pageIdSchema, ...(cliArgs?.categoryExtensions ? { serviceWorkerId: zod @@ -81,8 +81,8 @@ Example with arguments: \`(el) => { return; } - const mcpPage = cliArgs?.experimentalPageIdRouting - ? context.getPageById(request.params.pageId) + const mcpPage = pageId + ? context.getPageById(pageId) : context.getSelectedMcpPage(); const page: Page = mcpPage.pptrPage; @@ -97,7 +97,14 @@ Example with arguments: \`(el) => { const evaluatable = await getPageOrFrame(page, frames); - await performEvaluation(evaluatable, fnString, args, response, context); + await performEvaluation( + evaluatable, + fnString, + args, + response, + context, + mcpPage, + ); } finally { void Promise.allSettled(args.map(arg => arg.dispose())); } @@ -111,23 +118,27 @@ const performEvaluation = async ( args: Array>, response: Response, context: Context, + page?: ContextPage, ) => { const fn = await evaluatable.evaluateHandle(`(${fnString})`); try { - await context.waitForEventsAfterAction(async () => { - const result = await evaluatable.evaluate( - async (fn, ...args) => { - // @ts-expect-error no types for function fn - return JSON.stringify(await fn(...args)); - }, - fn, - ...args, - ); - response.appendResponseLine('Script ran on page and returned:'); - response.appendResponseLine('```json'); - response.appendResponseLine(`${result}`); - response.appendResponseLine('```'); - }); + await context.waitForEventsAfterAction( + async () => { + const result = await evaluatable.evaluate( + async (fn, ...args) => { + // @ts-expect-error no types for function fn + return JSON.stringify(await fn(...args)); + }, + fn, + ...args, + ); + response.appendResponseLine('Script ran on page and returned:'); + response.appendResponseLine('```json'); + response.appendResponseLine(`${result}`); + response.appendResponseLine('```'); + }, + page ? {page} : undefined, + ); } finally { void fn.dispose(); } diff --git a/tests/tools/page-id-routing-e2e.test.ts b/tests/tools/page-id-routing-e2e.test.ts new file mode 100644 index 000000000..6de4b8d6a --- /dev/null +++ b/tests/tools/page-id-routing-e2e.test.ts @@ -0,0 +1,421 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import {McpResponse} from '../../src/McpResponse.js'; +import {emulate} from '../../src/tools/emulation.js'; +import {navigatePage, selectPage} from '../../src/tools/pages.js'; +import {screenshot} from '../../src/tools/screenshot.js'; +import {evaluateScript} from '../../src/tools/script.js'; +import {takeSnapshot} from '../../src/tools/snapshot.js'; +import {serverHooks} from '../server.js'; +import {html, withMcpContext} from '../utils.js'; + +describe('pageId routing E2E', () => { + const server = serverHooks(); + + describe('basic pageId routing', () => { + it('screenshot targets the correct page via pageId', async () => { + server.addHtmlRoute('/page-a', html`

PAGE_A_CONTENT

`); + server.addHtmlRoute('/page-b', html`

PAGE_B_CONTENT

`); + + await withMcpContext(async (_response, context) => { + // Page 1: navigate to page-a + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/page-a')); + + // Page 2: navigate to page-b + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/page-b')); + + // Selected page is now page2 (newPage auto-selects) + assert.strictEqual(context.getSelectedMcpPage().id, page2.id); + + // Take screenshot targeting page1 via pageId + const resp1 = new McpResponse({} as ParsedArguments); + resp1.setPage(page1); + await screenshot.handler( + {params: {format: 'png'}, page: page1}, + resp1, + context, + ); + + // Take screenshot targeting page2 via pageId + const resp2 = new McpResponse({} as ParsedArguments); + resp2.setPage(page2); + await screenshot.handler( + {params: {format: 'png'}, page: page2}, + resp2, + context, + ); + + // Both should have produced images + assert.strictEqual(resp1.images.length, 1, 'page1 screenshot captured'); + assert.strictEqual(resp2.images.length, 1, 'page2 screenshot captured'); + + // Screenshots should be different (different page content) + assert.notStrictEqual( + resp1.images[0]!.data, + resp2.images[0]!.data, + 'screenshots from different pages should differ', + ); + }); + }); + + it('evaluate_script targets the correct page via pageId', async () => { + server.addHtmlRoute('/eval-a', html`

EVAL_PAGE_A

`); + server.addHtmlRoute('/eval-b', html`

EVAL_PAGE_B

`); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/eval-a')); + + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/eval-b')); + + // Selected is page2, but evaluate on page1 via pageId + const resp1 = new McpResponse({} as ParsedArguments); + const evalTool = evaluateScript({} as ParsedArguments); + await evalTool.handler( + { + params: { + function: '() => document.querySelector("#marker").textContent', + pageId: page1.id, + }, + }, + resp1, + context, + ); + + // Should get EVAL_PAGE_A (page1), not EVAL_PAGE_B (page2) + const output = resp1.responseLines.join('\n'); + assert.ok( + output.includes('EVAL_PAGE_A'), + `Expected 'EVAL_PAGE_A' in output, got: ${output}`, + ); + assert.ok( + !output.includes('EVAL_PAGE_B'), + `Should not contain 'EVAL_PAGE_B' in output`, + ); + }); + }); + + it('navigate_page targets the correct page via pageId', async () => { + server.addHtmlRoute('/start', html`

Start

`); + server.addHtmlRoute('/destination', html`

Destination

`); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/start')); + + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/start')); + + // Navigate page1 to /destination while page2 is selected + const resp = new McpResponse({} as ParsedArguments); + resp.setPage(page1); + await navigatePage.handler( + { + params: { + type: 'url', + url: server.getRoute('/destination'), + }, + page: page1, + }, + resp, + context, + ); + + // page1 should be at /destination + assert.ok( + page1.pptrPage.url().includes('/destination'), + `page1 should be at /destination, got: ${page1.pptrPage.url()}`, + ); + + // page2 should still be at /start (untouched) + assert.ok( + page2.pptrPage.url().includes('/start'), + `page2 should still be at /start, got: ${page2.pptrPage.url()}`, + ); + }); + }); + + it('fallback to selected page when pageId is not provided', async () => { + server.addHtmlRoute( + '/fallback', + html`

FALLBACK_CONTENT

`, + ); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/fallback')); + + // No pageId — should use selected page (page1) + const resp = new McpResponse({} as ParsedArguments); + const evalTool = evaluateScript({} as ParsedArguments); + await evalTool.handler( + { + params: { + function: '() => document.querySelector("#marker").textContent', + }, + }, + resp, + context, + ); + + const output = resp.responseLines.join('\n'); + assert.ok( + output.includes('FALLBACK_CONTENT'), + `Expected 'FALLBACK_CONTENT' in output, got: ${output}`, + ); + }); + }); + }); + + describe('race condition simulation', () => { + it('pageId prevents cross-page contamination during interleaved select_page calls', async () => { + server.addHtmlRoute( + '/agent-a', + html`

AGENT_A_CONTENT

`, + ); + server.addHtmlRoute( + '/agent-b', + html`

AGENT_B_CONTENT

`, + ); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/agent-a')); + + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/agent-b')); + + // --- Simulate race condition --- + // Agent A: select_page(page1) + const respSelectA = new McpResponse({} as ParsedArguments); + await selectPage.handler( + {params: {pageId: page1.id}}, + respSelectA, + context, + ); + assert.strictEqual(context.getSelectedMcpPage().id, page1.id); + + // Agent B: select_page(page2) — interrupts! + const respSelectB = new McpResponse({} as ParsedArguments); + await selectPage.handler( + {params: {pageId: page2.id}}, + respSelectB, + context, + ); + assert.strictEqual(context.getSelectedMcpPage().id, page2.id); + + // Agent A: take_screenshot(pageId: page1.id) — uses explicit pageId + const respScreenshot = new McpResponse({} as ParsedArguments); + respScreenshot.setPage(page1); + await screenshot.handler( + {params: {format: 'png'}, page: page1}, + respScreenshot, + context, + ); + + // Agent A: evaluate_script(pageId: page1.id) — verify correct page + const respEval = new McpResponse({} as ParsedArguments); + const evalTool = evaluateScript({} as ParsedArguments); + await evalTool.handler( + { + params: { + function: '() => document.querySelector("#marker").textContent', + pageId: page1.id, + }, + }, + respEval, + context, + ); + + const evalOutput = respEval.responseLines.join('\n'); + // Despite select_page(page2) happening in between, + // the pageId-routed call should still get page1's content + assert.ok( + evalOutput.includes('AGENT_A_CONTENT'), + `Expected 'AGENT_A_CONTENT' but got: ${evalOutput}`, + ); + assert.ok( + !evalOutput.includes('AGENT_B_CONTENT'), + `Should NOT contain 'AGENT_B_CONTENT'`, + ); + + // Verify selected page is still page2 (Agent B's selection) + assert.strictEqual( + context.getSelectedMcpPage().id, + page2.id, + 'Global selected page should still be page2', + ); + }); + }); + + it('snapshot targets the correct page via pageId during interleaved calls', async () => { + server.addHtmlRoute( + '/snap-a', + html`

Snapshot A Heading

`, + ); + server.addHtmlRoute( + '/snap-b', + html`

Snapshot B Heading

`, + ); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/snap-a')); + + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/snap-b')); + + // Select page2 (simulate agent B hijacking selection) + context.selectPage(page2); + + // Take snapshot on page1 via explicit page targeting + const resp1 = new McpResponse({} as ParsedArguments); + resp1.setPage(page1); + await takeSnapshot.handler({params: {}, page: page1}, resp1, context); + + // Finalize snapshot + const {content: content1} = await resp1.handle( + 'take_snapshot', + context, + ); + const text1 = content1 + .filter(c => c.type === 'text') + .map(c => (c as {text: string}).text) + .join('\n'); + + // Should contain page1's content, not page2's + assert.ok( + text1.includes('Snapshot A Heading') || text1.includes('Button A'), + `Snapshot should contain page1 content, got: ${text1.substring(0, 200)}`, + ); + }); + }); + }); + + describe('waitForEventsAfterAction page isolation', () => { + it('uses the explicit page emulation settings for timeout calculation', async () => { + server.addHtmlRoute( + '/throttled', + html`

Throttled Page

reload`, + ); + server.addHtmlRoute('/normal', html`

Normal Page

`); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/throttled')); + + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/normal')); + + // Apply CPU throttling to page1 (4x slowdown) + const emulateResp = new McpResponse({} as ParsedArguments); + emulateResp.setPage(page1); + await emulate.handler( + { + params: {cpuThrottlingRate: 4}, + page: page1, + }, + emulateResp, + context, + ); + + // Verify page1 has throttling, page2 doesn't + assert.strictEqual(page1.cpuThrottlingRate, 4); + assert.strictEqual(page2.cpuThrottlingRate, 1); + + // Select page2 (global state now points to normal page) + context.selectPage(page2); + assert.strictEqual(context.getSelectedMcpPage().id, page2.id); + + // Navigate page1 via explicit page — this calls waitForEventsAfterAction + // with {page: page1}. Before our fix, it would have used page2's settings. + const navResp = new McpResponse({} as ParsedArguments); + navResp.setPage(page1); + await navigatePage.handler( + { + params: { + type: 'reload', + }, + page: page1, + }, + navResp, + context, + ); + + // Navigation should succeed — page1 is still at /throttled + assert.ok( + page1.pptrPage.url().includes('/throttled'), + `page1 should still be at /throttled after reload`, + ); + + // page2 should be untouched at /normal + assert.ok( + page2.pptrPage.url().includes('/normal'), + `page2 should still be at /normal`, + ); + + // page1 should still have throttling applied + assert.strictEqual( + page1.cpuThrottlingRate, + 4, + 'page1 CPU throttling should persist after navigation', + ); + }); + }); + + it('different pages have independent emulation settings', async () => { + server.addHtmlRoute('/emu-a', html`

Emulation A

`); + server.addHtmlRoute('/emu-b', html`

Emulation B

`); + + await withMcpContext(async (_response, context) => { + const page1 = context.getSelectedMcpPage(); + await page1.pptrPage.goto(server.getRoute('/emu-a')); + + const page2 = await context.newPage(); + await page2.pptrPage.goto(server.getRoute('/emu-b')); + + // Set network throttling on page1 + const resp1 = new McpResponse({} as ParsedArguments); + resp1.setPage(page1); + await emulate.handler( + { + params: {networkConditions: 'Slow 3G'}, + page: page1, + }, + resp1, + context, + ); + + // Set CPU throttling on page2 + const resp2 = new McpResponse({} as ParsedArguments); + resp2.setPage(page2); + await emulate.handler( + { + params: {cpuThrottlingRate: 8}, + page: page2, + }, + resp2, + context, + ); + + // Verify isolation + assert.strictEqual(page1.networkConditions, 'Slow 3G'); + assert.strictEqual(page1.cpuThrottlingRate, 1); // default + + assert.strictEqual(page2.networkConditions, null); // default + assert.strictEqual(page2.cpuThrottlingRate, 8); + }); + }); + }); +}); diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index 772ebc076..0974f97e3 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -257,7 +257,7 @@ describe('script', () => { params: { function: String(() => 'test'), serviceWorkerId: 'example_service_worker', - pageId: '1', + pageId: 1, }, }, response,