diff --git a/src/McpContext.ts b/src/McpContext.ts index 5975ed660..7e6aa23da 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -34,11 +34,7 @@ import {Locator} from './third_party/index.js'; import {PredefinedNetworkConditions} from './third_party/index.js'; import {listPages} from './tools/pages.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; -import type { - Context, - DevToolsData, - ContextPage, -} from './tools/ToolDefinition.js'; +import type {Context, DevToolsData} from './tools/ToolDefinition.js'; import type {TraceResult} from './trace-processing/parse.js'; import type { EmulationSettings, @@ -116,7 +112,6 @@ export class McpContext implements Context { #isRunningTrace = false; #screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null = null; - #focusedPagePerContext = new Map(); #nextPageId = 1; @@ -302,10 +297,6 @@ export class McpContext implements Context { page.dispose(); this.#mcpPages.delete(page.pptrPage); } - const ctx = page.pptrPage.browserContext(); - if (this.#focusedPagePerContext.get(ctx) === page.pptrPage) { - this.#focusedPagePerContext.delete(ctx); - } await page.pptrPage.close({runBeforeUnload: false}); } @@ -499,38 +490,9 @@ export class McpContext implements Context { return this.#selectedPage?.pptrPage === page; } - assertPageIsFocused(pageToCheck: Page | ContextPage): void { - const page = 'pptrPage' in pageToCheck ? pageToCheck.pptrPage : pageToCheck; - const ctx = page.browserContext(); - const focused = this.#focusedPagePerContext.get(ctx); - if (focused && focused !== page) { - const targetId = this.#mcpPages.get(page)?.id ?? '?'; - const focusedId = this.#mcpPages.get(focused)?.id ?? '?'; - throw new Error( - `Page ${targetId} is not the active page in its browser context (page ${focusedId} is). ` + - `Call select_page with pageId ${targetId} first.`, - ); - } - } - selectPage(newPage: McpPage): void { - const ctx = newPage.pptrPage.browserContext(); - const oldFocused = this.#focusedPagePerContext.get(ctx); - if ( - oldFocused && - oldFocused !== newPage.pptrPage && - !oldFocused.isClosed() - ) { - void oldFocused.emulateFocusedPage(false).catch(error => { - this.logger('Error turning off focused page emulation', error); - }); - } - this.#focusedPagePerContext.set(ctx, newPage.pptrPage); this.#selectedPage = newPage; this.#updateSelectedPageTimeouts(); - void newPage.pptrPage.emulateFocusedPage(true).catch(error => { - this.logger('Error turning on focused page emulation', error); - }); } #updateSelectedPageTimeouts() { @@ -606,6 +568,10 @@ export class McpContext implements Context { if (!mcpPage) { mcpPage = new McpPage(page, this.#nextPageId++); this.#mcpPages.set(page, mcpPage); + // We emulate a focused page for all pages to support multi-agent workflows. + void page.emulateFocusedPage(true).catch(error => { + this.logger('Error turning on focused page emulation', error); + }); } mcpPage.isolatedContextName = isolatedContextNames.get(page); } @@ -618,12 +584,6 @@ export class McpContext implements Context { this.#mcpPages.delete(page); } } - // Prune stale #focusedPagePerContext entries. - for (const [ctx, page] of this.#focusedPagePerContext) { - if (!currentPages.has(page)) { - this.#focusedPagePerContext.delete(ctx); - } - } this.#pages = allPages.filter(page => { return ( diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 5fe2a9f3d..da3085705 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -142,7 +142,6 @@ export type Context = Readonly<{ ): Promise; closePage(pageId: number): Promise; selectPage(page: ContextPage): void; - assertPageIsFocused(page: Page): void; restoreEmulation(page: ContextPage): Promise; emulate( options: { diff --git a/src/tools/input.ts b/src/tools/input.ts index 2c9b46dce..5eba57cb9 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -98,7 +98,6 @@ export const clickAt = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - context.assertPageIsFocused(page.pptrPage); await context.waitForEventsAfterAction(async () => { await page.pptrPage.mouse.click(request.params.x, request.params.y, { clickCount: request.params.dblClick ? 2 : 1, @@ -263,7 +262,6 @@ export const typeText = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - context.assertPageIsFocused(page.pptrPage); await context.waitForEventsAfterAction(async () => { await page.pptrPage.keyboard.type(request.params.text); if (request.params.submitKey) { @@ -416,7 +414,6 @@ export const pressKey = definePageTool({ }, handler: async (request, response, context) => { const page = request.page; - context.assertPageIsFocused(page.pptrPage); const tokens = parseKey(request.params.key); const [key, ...modifiers] = tokens; diff --git a/tests/tools/pageFocus.test.ts b/tests/tools/pageFocus.test.ts deleted file mode 100644 index 774f4359b..000000000 --- a/tests/tools/pageFocus.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * @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/cli.js'; -import {McpResponse} from '../../src/McpResponse.js'; -import {clickAt, pressKey, typeText} from '../../src/tools/input.js'; -import {html, withMcpContext} from '../utils.js'; - -const emptyArgs = {} as ParsedArguments; - -describe('assertPageIsFocused', () => { - describe('McpContext method', () => { - it('passes for the only page in an isolated context', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(false, 'ctx-a'); - assert.doesNotThrow(() => context.assertPageIsFocused(page)); - }); - }); - - it('throws when a different page is focused in the same context', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - const pageA2 = await context.newPage(false, 'ctx-a'); - assert.doesNotThrow(() => context.assertPageIsFocused(pageA2)); - assert.throws( - () => context.assertPageIsFocused(pageA1), - (err: Error) => { - assert.ok(err.message.includes('not the active page')); - assert.ok(err.message.includes('Call select_page')); - return true; - }, - ); - }); - }); - - it('passes after re-selecting the page', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - await context.newPage(false, 'ctx-a'); - assert.throws(() => context.assertPageIsFocused(pageA1)); - context.selectPage(pageA1); - assert.doesNotThrow(() => context.assertPageIsFocused(pageA1)); - }); - }); - - it('does not cross-context interfere', async () => { - await withMcpContext(async (_response, context) => { - const pageA = await context.newPage(false, 'ctx-a'); - const pageB = await context.newPage(false, 'ctx-b'); - assert.doesNotThrow(() => context.assertPageIsFocused(pageA)); - assert.doesNotThrow(() => context.assertPageIsFocused(pageB)); - }); - }); - - it('tracks focus independently per context', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - const pageA2 = await context.newPage(false, 'ctx-a'); - const pageB1 = await context.newPage(false, 'ctx-b'); - const pageB2 = await context.newPage(false, 'ctx-b'); - - // Latest page in each context is focused. - assert.doesNotThrow(() => context.assertPageIsFocused(pageA2)); - assert.doesNotThrow(() => context.assertPageIsFocused(pageB2)); - assert.throws(() => context.assertPageIsFocused(pageA1)); - assert.throws(() => context.assertPageIsFocused(pageB1)); - - // Switch focus within each context independently. - context.selectPage(pageA1); - context.selectPage(pageB1); - assert.doesNotThrow(() => context.assertPageIsFocused(pageA1)); - assert.doesNotThrow(() => context.assertPageIsFocused(pageB1)); - assert.throws(() => context.assertPageIsFocused(pageA2)); - assert.throws(() => context.assertPageIsFocused(pageB2)); - }); - }); - }); - - describe('type_text', () => { - it('throws when targeting a non-focused page', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - await pageA1.pptrPage.setContent(html``); - await pageA1.pptrPage.click('textarea'); - await context.newPage(false, 'ctx-a'); - - await assert.rejects( - () => - typeText.handler( - {params: {text: 'fail'}, page: pageA1}, - new McpResponse(emptyArgs), - context, - ), - (err: Error) => { - assert.ok(err.message.includes('not the active page')); - return true; - }, - ); - }); - }); - - it('succeeds on the focused page', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(false, 'ctx-a'); - await page.pptrPage.setContent(html``); - await page.pptrPage.click('textarea'); - - const response = new McpResponse(emptyArgs); - await typeText.handler( - {params: {text: 'hello'}, page}, - response, - context, - ); - assert.strictEqual(response.responseLines[0], 'Typed text "hello"'); - assert.strictEqual( - await page.pptrPage.evaluate( - () => document.querySelector('textarea')?.value, - ), - 'hello', - ); - }); - }); - - it('succeeds after re-selecting the correct page', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - await pageA1.pptrPage.setContent(html``); - await context.newPage(false, 'ctx-a'); - - await assert.rejects(() => - typeText.handler( - {params: {text: 'fail'}, page: pageA1}, - new McpResponse(emptyArgs), - context, - ), - ); - - context.selectPage(pageA1); - await pageA1.pptrPage.click('textarea'); - - const response = new McpResponse(emptyArgs); - await typeText.handler( - {params: {text: 'recovered'}, page: pageA1}, - response, - context, - ); - assert.strictEqual(response.responseLines[0], 'Typed text "recovered"'); - }); - }); - }); - - describe('press_key', () => { - it('throws when targeting a non-focused page', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - await pageA1.pptrPage.setContent(html`
content
`); - await context.newPage(false, 'ctx-a'); - - await assert.rejects( - () => - pressKey.handler( - {params: {key: 'Tab'}, page: pageA1}, - new McpResponse(emptyArgs), - context, - ), - (err: Error) => { - assert.ok(err.message.includes('not the active page')); - return true; - }, - ); - }); - }); - - it('succeeds on the focused page', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(false, 'ctx-a'); - await page.pptrPage.setContent( - html``, - ); - - const response = new McpResponse(emptyArgs); - await pressKey.handler( - {params: {key: 'Enter'}, page}, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully pressed key: Enter', - ); - assert.deepStrictEqual(await page.pptrPage.evaluate('logs'), ['Enter']); - }); - }); - }); - - describe('click_at', () => { - it('throws when targeting a non-focused page', async () => { - await withMcpContext(async (_response, context) => { - const pageA1 = await context.newPage(false, 'ctx-a'); - await pageA1.pptrPage.setContent( - html`
`, - ); - await context.newPage(false, 'ctx-a'); - - await assert.rejects( - () => - clickAt.handler( - {params: {x: 50, y: 50}, page: pageA1}, - new McpResponse(emptyArgs), - context, - ), - (err: Error) => { - assert.ok(err.message.includes('not the active page')); - return true; - }, - ); - }); - }); - - it('succeeds on the focused page', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(false, 'ctx-a'); - await page.pptrPage.setContent( - html`
`, - ); - - const response = new McpResponse(emptyArgs); - await clickAt.handler( - {params: {x: 50, y: 50}, page}, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully clicked at the coordinates', - ); - assert.ok(await page.pptrPage.$('text/clicked')); - }); - }); - }); - - describe('cross-context isolation', () => { - it('type_text in one context does not affect another', async () => { - await withMcpContext(async (_response, context) => { - const pageA = await context.newPage(false, 'ctx-a'); - await pageA.pptrPage.setContent(html``); - - const pageB = await context.newPage(false, 'ctx-b'); - await pageB.pptrPage.setContent(html``); - - context.selectPage(pageA); - await pageA.pptrPage.click('textarea'); - await typeText.handler( - {params: {text: 'agent-a'}, page: pageA}, - new McpResponse(emptyArgs), - context, - ); - - context.selectPage(pageB); - await pageB.pptrPage.click('textarea'); - await typeText.handler( - {params: {text: 'agent-b'}, page: pageB}, - new McpResponse(emptyArgs), - context, - ); - - assert.strictEqual( - await pageA.pptrPage.evaluate( - () => document.querySelector('textarea')?.value, - ), - 'agent-a', - ); - assert.strictEqual( - await pageB.pptrPage.evaluate( - () => document.querySelector('textarea')?.value, - ), - 'agent-b', - ); - }); - }); - - it('switching focus in context A does not break context B', async () => { - await withMcpContext(async (_response, context) => { - await context.newPage(false, 'ctx-a'); - const pageA2 = await context.newPage(false, 'ctx-a'); - await pageA2.pptrPage.setContent(html`
A2
`); - - const pageB = await context.newPage(false, 'ctx-b'); - await pageB.pptrPage.setContent(html``); - - // ctx-a focus is on pageA2, ctx-b focus is on pageB. - await pageB.pptrPage.click('textarea'); - const response = new McpResponse(emptyArgs); - await typeText.handler( - {params: {text: 'still works'}, page: pageB}, - response, - context, - ); - assert.strictEqual( - await pageB.pptrPage.evaluate( - () => document.querySelector('textarea')?.value, - ), - 'still works', - ); - }); - }); - }); -}); diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index d2828be9a..454ffc657 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -350,7 +350,7 @@ describe('pages', () => { await context .getPageById(1) .pptrPage.evaluate(() => document.hasFocus()), - false, + true, ); await selectPage.handler({params: {pageId: 1}}, response, context); assert.strictEqual( @@ -410,121 +410,6 @@ describe('pages', () => { ); }); }); - it('focuses correct same-context page after cross-context interleaving', async () => { - await withMcpContext(async (response, context) => { - // Create 2 pages in ctx-a, 1 in ctx-b. - await newPage.handler( - {params: {url: 'about:blank', isolatedContext: 'ctx-a'}}, - response, - context, - ); - const pageA1 = context.getSelectedPptrPage(); - const pageA1Id = context.getPageId(pageA1)!; - - await newPage.handler( - {params: {url: 'about:blank', isolatedContext: 'ctx-b'}}, - response, - context, - ); - const pageB = context.getSelectedPptrPage(); - - // pageA1 still focused (cross-context select doesn't defocus it). - assert.strictEqual( - await pageA1.evaluate(() => document.hasFocus()), - true, - ); - - // Create second page in ctx-a. This should defocus pageA1, - // even though #selectedPage was pageB (different context). - await newPage.handler( - {params: {url: 'about:blank', isolatedContext: 'ctx-a'}}, - response, - context, - ); - const pageA2 = context.getSelectedPptrPage(); - - // pageA1 and pageA2 share the same BrowserContext. - assert.strictEqual(pageA1.browserContext(), pageA2.browserContext()); - - assert.strictEqual( - await pageA1.evaluate(() => document.hasFocus()), - false, - 'pageA1 should lose focus when pageA2 is created in the same context', - ); - assert.strictEqual( - await pageA2.evaluate(() => document.hasFocus()), - true, - ); - // pageB is unaffected by ctx-a changes. - assert.strictEqual( - await pageB.evaluate(() => document.hasFocus()), - true, - ); - - // Re-selecting pageA1 should grant it focus via the override. - await selectPage.handler( - {params: {pageId: pageA1Id}}, - response, - context, - ); - assert.strictEqual( - await pageA1.evaluate(() => document.hasFocus()), - true, - ); - // pageB still unaffected. - assert.strictEqual( - await pageB.evaluate(() => document.hasFocus()), - true, - ); - }); - }); - it('handles focus correctly after closing the focused page in a context', async () => { - await withMcpContext(async (response, context) => { - await newPage.handler( - {params: {url: 'about:blank', isolatedContext: 'ctx-a'}}, - response, - context, - ); - const pageA1 = context.getSelectedPptrPage(); - - await newPage.handler( - {params: {url: 'about:blank', isolatedContext: 'ctx-a'}}, - response, - context, - ); - const pageA2 = context.getSelectedPptrPage(); - const pageA2Id = context.getPageId(pageA2)!; - - // pageA2 is focused, pageA1 is not. - assert.strictEqual( - await pageA2.evaluate(() => document.hasFocus()), - true, - ); - assert.strictEqual( - await pageA1.evaluate(() => document.hasFocus()), - false, - ); - - // Close pageA2 (the focused page). - await closePage.handler( - {params: {pageId: pageA2Id}}, - response, - context, - ); - - // Selecting pageA1 should work without errors. - const pageA1Id = context.getPageId(pageA1)!; - await selectPage.handler( - {params: {pageId: pageA1Id}}, - response, - context, - ); - assert.strictEqual( - await pageA1.evaluate(() => document.hasFocus()), - true, - ); - }); - }); }); describe('navigate_page', () => { it('navigates to correct page', async () => {