Skip to content

Commit c96569b

Browse files
committed
fix: skip unresponsive page targets
1 parent 2892474 commit c96569b

2 files changed

Lines changed: 144 additions & 9 deletions

File tree

src/McpContext.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ interface McpContextOptions {
5959
const DEFAULT_TIMEOUT = 5_000;
6060
const NAVIGATION_TIMEOUT = 10_000;
6161

62+
async function resolveWithTimeout<T>(
63+
promise: Promise<T>,
64+
timeoutMs: number,
65+
): Promise<T | undefined> {
66+
let timeout: ReturnType<typeof setTimeout> | undefined;
67+
const timeoutPromise = new Promise<undefined>(resolve => {
68+
timeout = setTimeout(() => {
69+
resolve(undefined);
70+
}, timeoutMs);
71+
});
72+
try {
73+
return await Promise.race([promise, timeoutPromise]);
74+
} finally {
75+
if (timeout !== undefined) {
76+
clearTimeout(timeout);
77+
}
78+
}
79+
}
80+
6281
export class McpContext implements Context {
6382
browser: Browser;
6483
logger: Debugger;
@@ -564,9 +583,20 @@ export class McpContext implements Context {
564583
isolatedContextNames: Map<Page, string>;
565584
}> {
566585
const defaultCtx = this.browser.defaultBrowserContext();
567-
const allPages = await this.browser.pages(
568-
this.#options.experimentalIncludeAllPages,
569-
);
586+
const pagePromises: Array<Promise<Page | null>> = [];
587+
for (const target of this.browser.targets()) {
588+
if (!this.#isPageTarget(target)) {
589+
continue;
590+
}
591+
pagePromises.push(this.#resolveTargetPage(target));
592+
}
593+
594+
const allPages: Page[] = [];
595+
for (const page of await Promise.all(pagePromises)) {
596+
if (page && !allPages.includes(page)) {
597+
allPages.push(page);
598+
}
599+
}
570600

571601
const allTargets = this.browser.targets();
572602
const extensionTargets = allTargets.filter(target => {
@@ -578,17 +608,15 @@ export class McpContext implements Context {
578608

579609
for (const target of extensionTargets) {
580610
// Right now target.page() returns null for popup and side panel pages.
581-
let page = await target.page();
611+
let page = await this.#resolveTargetPage(target);
582612
if (!page) {
583613
// We need to cache pages instances for targets because target.asPage()
584614
// returns a new page instance every time.
585615
page = this.#extensionPages.get(target) ?? null;
586616
if (!page) {
587-
try {
588-
page = await target.asPage();
617+
page = await this.#resolveTargetPage(target, true);
618+
if (page) {
589619
this.#extensionPages.set(target, page);
590-
} catch (e) {
591-
this.logger('Failed to get page for extension target', e);
592620
}
593621
}
594622
}
@@ -628,6 +656,42 @@ export class McpContext implements Context {
628656
return {pages: allPages, isolatedContextNames};
629657
}
630658

659+
#isPageTarget(target: Target): boolean {
660+
const type = target.type();
661+
if (type === 'page') {
662+
return true;
663+
}
664+
if (!this.#options.experimentalIncludeAllPages) {
665+
return false;
666+
}
667+
return type === 'background_page' || type === 'webview' || type === 'other';
668+
}
669+
670+
async #resolveTargetPage(
671+
target: Target,
672+
force = false,
673+
): Promise<Page | null> {
674+
try {
675+
const pagePromise: Promise<Page | null> = force
676+
? target.asPage()
677+
: target.page();
678+
const page = await resolveWithTimeout(pagePromise, DEFAULT_TIMEOUT);
679+
if (page === undefined) {
680+
this.logger(
681+
`Timed out getting page for target ${target.type()} ${target.url()}`,
682+
);
683+
return null;
684+
}
685+
return page;
686+
} catch (error) {
687+
this.logger(
688+
`Failed to get page for target ${target.type()} ${target.url()}`,
689+
error,
690+
);
691+
return null;
692+
}
693+
}
694+
631695
async detectOpenDevToolsWindows() {
632696
this.logger('Detecting open DevTools windows');
633697
const {pages} = await this.#getAllPages();

tests/McpContext.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,63 @@ import path from 'node:path';
99
import {afterEach, describe, it} from 'node:test';
1010
import {pathToFileURL} from 'node:url';
1111

12+
import {TargetType} from 'puppeteer-core';
1213
import sinon from 'sinon';
1314

1415
import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js';
1516
import {TextSnapshot} from '../src/TextSnapshot.js';
16-
import type {HTTPResponse} from '../src/third_party/index.js';
17+
import type {
18+
Browser,
19+
BrowserContext,
20+
CDPSession,
21+
HTTPResponse,
22+
Page,
23+
Target,
24+
WebWorker,
25+
} from '../src/third_party/index.js';
1726
import type {TraceResult} from '../src/trace-processing/parse.js';
1827

1928
import {getMockRequest, html, withMcpContext} from './utils.js';
2029

30+
function createHangingPageTarget(
31+
browser: Browser,
32+
browserContext: BrowserContext,
33+
): Target {
34+
return {
35+
page(): Promise<Page | null> {
36+
return new Promise(() => {
37+
// Intentionally never resolves to mimic a stalled target.
38+
});
39+
},
40+
asPage(): Promise<Page> {
41+
return new Promise(() => {
42+
// Intentionally never resolves to mimic a stalled target.
43+
});
44+
},
45+
url(): string {
46+
return 'https://example.com/hanging';
47+
},
48+
createCDPSession(): Promise<CDPSession> {
49+
return Promise.reject(new Error('Not implemented'));
50+
},
51+
type(): TargetType {
52+
return TargetType.PAGE;
53+
},
54+
browser(): Browser {
55+
return browser;
56+
},
57+
browserContext(): BrowserContext {
58+
return browserContext;
59+
},
60+
opener(): Target | undefined {
61+
return;
62+
},
63+
worker(): Promise<WebWorker | null> {
64+
return Promise.resolve(null);
65+
},
66+
};
67+
}
68+
2169
describe('McpContext', () => {
2270
afterEach(() => {
2371
sinon.restore();
@@ -100,6 +148,29 @@ describe('McpContext', () => {
100148
},
101149
);
102150
});
151+
152+
it('skips page targets that do not resolve while creating a pages snapshot', async () => {
153+
await withMcpContext(async (_response, context) => {
154+
const page = context.getSelectedPptrPage();
155+
const targets = context.browser.targets();
156+
const hangingTarget = createHangingPageTarget(
157+
context.browser,
158+
page.browserContext(),
159+
);
160+
sinon
161+
.stub(context.browser, 'targets')
162+
.returns([...targets, hangingTarget]);
163+
sinon.stub(context, 'detectOpenDevToolsWindows').resolves();
164+
const clock = sinon.useFakeTimers();
165+
166+
const pagesPromise = context.createPagesSnapshot();
167+
await clock.tickAsync(5_000);
168+
const pages = await pagesPromise;
169+
170+
assert.ok(pages.includes(page));
171+
});
172+
});
173+
103174
it('resolves uid from a non-selected page snapshot', async () => {
104175
await withMcpContext(async (_response, context) => {
105176
// Page 1: set content and snapshot

0 commit comments

Comments
 (0)