mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
305c06d064
commit
28fb230dde
5 files changed
+154
-17
No files matched your search
+55
-15
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user