feat: report pages opened by input actions (#2679)

Fixes #367

Report pages opened by input actions using the source page popup events.
The response lists only opened pages under `## Opened pages` with their
MCP page ID, title, and URL, and exposes them as
`structuredContent.openedPages`. Already-open pages are excluded; a full
page list is not implicitly enabled.

The popup set uses a WeakSet. Before formatting a response, the page
snapshot is refreshed so opened pages have MCP IDs. The response follows
the context page order rather than promising event order. Shared page
formatting also covers the regular and extension page lists.

Tests cover a popup alongside an unrelated page, multiple popups without
depending on order, and listener cleanup.

## Testing

- `npm run format` and `npm run build` passed.
- `npm run test tests/utils/WaitForHelper.test.ts` and `npm run test
tests/tools/input.test.ts` passed with system Chrome.
- The full `npm run test` run encounters `Page.captureScreenshot: Page
is too large` in the large full-page screenshot test; the same failure
reproduces on an unchanged `main` checkout with the same Chrome.
This commit is contained in:
lisa0314 authored and GitHub committed 2026-10-14 09:53:16 +00:00
1 parent 305c06d064
commit 28fb230dde
5 files changed
+154 -17

No files matched your search

+55 -15
View File
@@ -776,6 +776,10 @@ export class McpResponse implements Response {
content: Array<TextContent | ImageContent>;
structuredContent: object;
}> {
if (this.#attachedWaitForResult?.openedPages && !this.#includePages) {
await context.createPagesSnapshot();
}
const [
snapshot,
detailedNetworkRequest,
@@ -895,6 +899,7 @@ export class McpResponse implements Response {
defaultValue?: string;
};
pages?: object[];
openedPages?: object[];
pagination?: object;
heapSnapshot?: {
stats?: object;
@@ -971,6 +976,28 @@ export class McpResponse implements Response {
structuredContent.navigatedToUrl =
this.#attachedWaitForResult.navigatedToUrl;
}
if (this.#attachedWaitForResult.openedPages) {
const openedPages = this.#attachedWaitForResult.openedPages;
const openedMcpPages = context
.getPages()
.filter(mcpPage => openedPages.has(mcpPage.target));
if (openedMcpPages.length) {
const targetInfos = await context.getTargetInfos();
response.push('## Opened pages');
const structuredPages = [];
for (const mcpPage of openedMcpPages) {
const page = formatPage(
mcpPage,
context,
targetInfos.get(mcpPage.targetId),
);
response.push(page.text);
structuredPages.push(page.structured);
}
structuredContent.openedPages = structuredPages;
}
}
}
const page = this.#page;
@@ -1094,14 +1121,15 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
const contextLabel = isolatedContextName
? ` isolatedContext=${isolatedContextName}`
: '';
const title = mcpPage.getTitle(targetInfos.get(mcpPage.targetId));
const pageLabel = title
? `${truncateTitle(title)} (${mcpPage.url()})`
: mcpPage.url();
parts.push(
`${mcpPage.id}: ${pageLabel}${context.isPageSelected(mcpPage) ? ' [selected]' : ''}${contextLabel}`,
const page = formatPage(
mcpPage,
context,
targetInfos.get(mcpPage.targetId),
);
structuredPages.push(createStructuredPage(mcpPage, context, title));
parts.push(
`${page.text}${context.isPageSelected(mcpPage) ? ' [selected]' : ''}${contextLabel}`,
);
structuredPages.push(page.structured);
}
response.push(...parts);
structuredContent.pages = structuredPages;
@@ -1116,16 +1144,15 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
const contextLabel = isolatedContextName
? ` isolatedContext=${isolatedContextName}`
: '';
const title = mcpPage.getTitle(targetInfos.get(mcpPage.targetId));
const pageLabel = title
? `${truncateTitle(title)} (${mcpPage.url()})`
: mcpPage.url();
const page = formatPage(
mcpPage,
context,
targetInfos.get(mcpPage.targetId),
);
response.push(
`${mcpPage.id}: ${pageLabel}${context.isPageSelected(mcpPage) ? ' [selected]' : ''}${contextLabel}`,
);
structuredExtensionPages.push(
createStructuredPage(mcpPage, context, title),
`${page.text}${context.isPageSelected(mcpPage) ? ' [selected]' : ''}${contextLabel}`,
);
structuredExtensionPages.push(page.structured);
}
structuredContent.extensionPages = structuredExtensionPages;
}
@@ -1789,6 +1816,19 @@ const collapseWhitespace = (value: string): string => {
return value.replace(/\s+/g, ' ').trim();
};
function formatPage(
mcpPage: McpPage,
context: McpContext,
targetInfo: Protocol.Target.TargetInfo | undefined,
): {text: string; structured: ReturnType<typeof createStructuredPage>} {
const title = mcpPage.getTitle(targetInfo);
const url = mcpPage.url();
const pageLabel = title ? `${truncateTitle(title)} (${url})` : url;
return {
text: `${mcpPage.id}: ${pageLabel}`,
structured: createStructuredPage(mcpPage, context, title),
};
}
function createStructuredPage(
mcpPage: McpPage,
context: McpContext,
+16
View File
@@ -5,6 +5,7 @@
*/
import type {
Target,
Page,
Protocol,
CdpPage,
@@ -18,6 +19,7 @@ export type DialogAction = 'accept' | 'dismiss' | string;
export class WaitForHelper {
#abortController = new AbortController();
#page: CdpPage;
#publicPage: Page;
#stableDomTimeout: number;
#stableDomFor: number;
#expectNavigationIn: number;
@@ -27,6 +29,7 @@ export class WaitForHelper {
/** Track all dialogs as they pause the renderer. */
#dialogDetected = false;
#initialUrl: string;
#openedPages = new Set<Target>();
constructor(
page: Page,
@@ -38,6 +41,7 @@ export class WaitForHelper {
this.#expectNavigationIn = 100 * cpuTimeoutMultiplier;
this.#navigationTimeout = 3000 * networkTimeoutMultiplier;
this.#page = page as unknown as CdpPage;
this.#publicPage = page;
this.#initialUrl = page.url();
}
@@ -163,6 +167,16 @@ export class WaitForHelper {
this.#page.off('dialog', dialogHandler);
});
const popupHandler = (page: Page | null): void => {
if (page) {
this.#openedPages.add(page.target());
}
};
this.#publicPage.on('popup', popupHandler);
this.#abortController.signal.addEventListener('abort', () => {
this.#publicPage.off('popup', popupHandler);
});
// A scoped AbortController used to clean up navigation probe listeners.
// When aborted (either after navigation detection finishes or if this.#abortController
// aborts), it removes CDP navigation probe listeners and automatically
@@ -292,6 +306,7 @@ export class WaitForHelper {
? {navigatedToUrl: urlAfterAction}
: {}),
dialogHandled: this.#dialogHandled,
...(this.#openedPages.size ? {openedPages: this.#openedPages} : {}),
};
}
}
@@ -306,6 +321,7 @@ export interface WaitForEventsResult {
* Whether a dialog was automatically handled during the action.
*/
dialogHandled?: boolean;
openedPages?: Set<Target>;
}
export function getNetworkMultiplierFromString(
+54 -1
View File
@@ -7,7 +7,7 @@
import assert from 'node:assert';
import {readFile} from 'node:fs/promises';
import {join} from 'node:path';
import {describe, it} from 'node:test';
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
@@ -53,6 +53,7 @@ import {
createMockMcpWorker,
createMockObjectInfo,
createMockParsedArguments,
createMockPuppeteerTarget,
} from './mocks.js';
import {
createTempDir,
@@ -68,6 +69,58 @@ import {
} from './utils.js';
describe('McpResponse', () => {
afterEach(() => {
sinon.restore();
});
it('reports only pages opened by the attached action', async () => {
const openedTarget = createMockPuppeteerTarget({
url: 'https://example.com/opened',
title: 'Opened page',
});
const openedPage = createMockMcpPage({target: openedTarget});
const unrelatedPage = createMockMcpPage();
Object.defineProperty(openedPage, 'id', {value: 2});
openedPage.url.returns('https://example.com/opened');
openedPage.getTitle.returns('Opened page');
Object.defineProperty(unrelatedPage, 'id', {value: 3});
unrelatedPage.url.returns('https://example.com/unrelated');
unrelatedPage.getTitle.returns('Unrelated page');
const context = createMockMcpContext();
const targetInfo = openedTarget._getTargetInfo();
context.getTargetInfos.resolves(
new Map([[targetInfo.targetId, targetInfo]]),
);
context.getPages.returns([openedPage, unrelatedPage]);
context.isPageSelected.returns(false);
const response = new McpResponse(createMockParsedArguments());
response.attachWaitForResult({
openedPages: new Set([openedPage.target]),
});
const result = await response.handle(context);
sinon.assert.calledOnceWithExactly(context.createPagesSnapshot);
sinon.assert.calledOnceWithExactly(context.getTargetInfos);
sinon.assert.calledOnceWithExactly(openedPage.getTitle, targetInfo);
assert.match(
getTextContent(result.content[0]),
/## Opened pages\n2: Opened page \(https:\/\/example\.com\/opened\)/,
);
assert.ok(!getTextContent(result.content[0]).includes('Unrelated page'));
assert.deepStrictEqual(result.structuredContent, {
openedPages: [
{
id: 2,
url: 'https://example.com/opened',
title: 'Opened page',
selected: false,
},
],
});
});
it('list pages', async t => {
await withMcpContext(async (response, context) => {
response.setIncludePages(true);
+4
View File
@@ -133,6 +133,9 @@ export function mockListener() {
listener(data);
}
},
listenerCount(eventName: string | symbol | number) {
return listeners[eventName]?.length ?? 0;
},
};
}
@@ -178,6 +181,7 @@ export function createMockPuppeteerPage(): sinon.SinonStubbedInstance<Page> & {
CdpPage,
) as unknown as sinon.SinonStubbedInstance<Page>;
const pageListener = mockListener();
page.listenerCount.callsFake(pageListener.listenerCount);
page.on.callsFake((eventName, handler) => {
pageListener.on(eventName, handler);
return page;
+25 -1
View File
@@ -10,7 +10,11 @@ import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {WaitForHelper} from '../../src/utils/WaitForHelper.js';
import {createMockDialog, createMockPuppeteerPage} from '../mocks.js';
import {
createMockDialog,
createMockPuppeteerPage,
createMockPuppeteerTarget,
} from '../mocks.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext} from '../utils.js';
@@ -202,4 +206,24 @@ describe('WaitForHelper', () => {
assert.strictEqual(result.navigatedToUrl, targetUrl);
});
});
it('captures popup targets opened by the action', async () => {
const pptrPage = createMockPuppeteerPage();
pptrPage.url.returns('https://example.com');
pptrPage.waitForNavigation.returns(Promise.withResolvers<null>().promise);
const popupPage = createMockPuppeteerPage();
const popupTarget = createMockPuppeteerTarget({page: popupPage});
const helper = new WaitForHelper(pptrPage, 1, 1);
const result = await helper.waitForEventsAfterAction(
async () => {
assert.strictEqual(pptrPage.listenerCount('popup'), 1);
pptrPage.emit('popup', popupPage);
},
{waitForStableDom: false, expectNavigationIn: 0},
);
assert.strictEqual(result.openedPages?.has(popupTarget), true);
assert.strictEqual(pptrPage.listenerCount('popup'), 0);
});
});