Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page | null> {
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<Page[]> {
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 =
Expand Down Expand Up @@ -579,9 +640,22 @@ export class McpContext implements Context {
isolatedContextNames: Map<Page, string>;
}> {
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 => {
Expand All @@ -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.
Expand Down
38 changes: 37 additions & 1 deletion tests/McpContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down