diff --git a/src/McpContext.ts b/src/McpContext.ts index 3ebbeae95..468642a1b 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -60,6 +60,67 @@ interface McpContextOptions { const DEFAULT_TIMEOUT = 5_000; const NAVIGATION_TIMEOUT = 10_000; +const RECOVERABLE_PAGE_INITIALIZATION_TIMEOUTS = [ + 'Network.enable timed out', + 'Page.enable timed out', + 'Runtime.enable timed out', +]; + +function isRecoverablePageInitializationError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return RECOVERABLE_PAGE_INITIALIZATION_TIMEOUTS.some(timeout => { + return message.includes(timeout); + }); +} + +async function getPageFromTarget( + target: Target, + logger: Debugger, + operation: string, + skipRecoverableErrors = false, +): Promise { + try { + return await target.page(); + } catch (error) { + if ( + !skipRecoverableErrors || + !isRecoverablePageInitializationError(error) + ) { + throw error; + } + + logger( + `Skipping target during ${operation}: ${target.type()} ${target.url()}`, + error, + ); + return null; + } +} + +async function enumerateHealthyPagesByTarget( + browser: Browser, + logger: Debugger, +): Promise { + const pages: Page[] = []; + + for (const target of browser.targets()) { + if (target.type() !== 'page') { + continue; + } + + const page = await getPageFromTarget( + target, + logger, + 'fallback page enumeration', + true, + ); + if (page && !pages.includes(page)) { + pages.push(page); + } + } + + return pages; +} function getNetworkMultiplierFromString(condition: string | null): number { const puppeteerCondition = @@ -579,9 +640,22 @@ export class McpContext implements Context { isolatedContextNames: Map; }> { const defaultCtx = this.browser.defaultBrowserContext(); - const allPages = await this.browser.pages( - this.#options.experimentalIncludeAllPages, - ); + let allPages: Page[]; + try { + allPages = await this.browser.pages( + this.#options.experimentalIncludeAllPages, + ); + } catch (error) { + if (!isRecoverablePageInitializationError(error)) { + throw error; + } + + this.logger( + 'browser.pages() failed with a recoverable page initialization error, falling back to per-target enumeration', + error, + ); + allPages = await enumerateHealthyPagesByTarget(this.browser, this.logger); + } const allTargets = this.browser.targets(); const extensionTargets = allTargets.filter(target => { @@ -593,7 +667,12 @@ export class McpContext implements Context { for (const target of extensionTargets) { // Right now target.page() returns null for popup and side panel pages. - let page = await target.page(); + let page = await getPageFromTarget( + target, + this.logger, + 'extension target enumeration', + true, + ); if (!page) { // We need to cache pages instances for targets because target.asPage() // returns a new page instance every time. diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 075c94742..53e47bce0 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -10,7 +10,7 @@ import {afterEach, describe, it} from 'node:test'; import sinon from 'sinon'; import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; -import type {HTTPResponse} from '../src/third_party/index.js'; +import type {HTTPResponse, Target} from '../src/third_party/index.js'; import type {TraceResult} from '../src/trace-processing/parse.js'; import {getMockRequest, html, withMcpContext} from './utils.js'; @@ -37,6 +37,42 @@ describe('McpContext', () => { }); }); + it('falls back to healthy targets when browser.pages hits a recoverable timeout', async () => { + await withMcpContext(async (_response, context) => { + const healthyPage = context.getSelectedMcpPage().pptrPage; + const timeoutError = new Error( + "Network.enable timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.", + ); + const healthyTarget = { + type: () => { + return 'page'; + }, + url: () => { + return healthyPage.url(); + }, + page: sinon.stub().resolves(healthyPage), + } as unknown as Target; + const frozenTarget = { + type: () => { + return 'page'; + }, + url: () => { + return 'https://discarded.example.test/'; + }, + page: sinon.stub().rejects(timeoutError), + } as unknown as Target; + + sinon.stub(context.browser, 'pages').rejects(timeoutError); + sinon + .stub(context.browser, 'targets') + .returns([healthyTarget, frozenTarget]); + + const pages = await context.createPagesSnapshot(); + + assert.deepStrictEqual(pages, [healthyPage]); + }); + }); + it('can store and retrieve the latest performance trace', async () => { await withMcpContext(async (_response, context) => { const fakeTrace1 = {} as unknown as TraceResult;