fix(browser): 修复 download 事件的 pageIndex 过滤

为 browser_wait_for_event 的 download 路径补齐按页面 frame tree 过滤,避免多页面或动态 iframe 下载时误命中或漏命中。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-20 21:42:00 +08:00
co-authored by Claude Opus 4.7
parent f6ef59ef96
commit 40c718c584
2 changed files with 220 additions and 4 deletions
+43 -3
View File
@@ -4174,8 +4174,38 @@ async function waitForPageEvent({ page, timeoutMs, event }) {
});
}
async function waitForBrowserDownloadEvent(timeoutMs, event) {
const targets = await fetchJson(`${getHttpUrl()}/json/list`);
async function getPageFrameIds(page) {
const response = await sendCdpCommand(page, 'Page.getFrameTree', {});
const frameTree = response?.frameTree;
const frameIds = new Set();
function visitFrameTree(node) {
if (!node || typeof node !== 'object') {
return;
}
const frameId = node.frame?.id;
if (typeof frameId === 'string' && frameId) {
frameIds.add(frameId);
}
const childFrames = Array.isArray(node.childFrames) ? node.childFrames : [];
for (const childFrame of childFrames) {
visitFrameTree(childFrame);
}
}
visitFrameTree(frameTree);
if (frameIds.size === 0) {
throw new Error('Browser MCP could not determine the selected page frame IDs.');
}
return frameIds;
}
async function waitForBrowserDownloadEvent(page, timeoutMs, event) {
const [targets, frameIds] = await Promise.all([
fetchJson(`${getHttpUrl()}/json/list`),
getPageFrameIds(page),
]);
const browserTarget = Array.isArray(targets)
? targets.find((target) => target && typeof target === 'object' && target.type === 'browser')
: null;
@@ -4227,6 +4257,16 @@ async function waitForBrowserDownloadEvent(timeoutMs, event) {
if (message?.method !== 'Browser.downloadWillBegin') {
return;
}
const eventFrameId = message.params?.frameId;
if (!frameIds.has(eventFrameId)) {
const refreshedFrameIds = await getPageFrameIds(page);
if (!refreshedFrameIds.has(eventFrameId)) {
return;
}
for (const frameId of refreshedFrameIds) {
frameIds.add(frameId);
}
}
const observed = {
url: message.params?.url || '',
suggestedFilename: message.params?.suggestedFilename || '',
@@ -4248,7 +4288,7 @@ async function waitForBrowserDownloadEvent(timeoutMs, event) {
async function waitForMatchingEvent({ page, timeoutMs, event }) {
if (event.kind === 'download') {
return await waitForBrowserDownloadEvent(timeoutMs, event);
return await waitForBrowserDownloadEvent(page, timeoutMs, event);
}
return await waitForPageEvent({ page, timeoutMs, event });
}
+177 -1
View File
@@ -84,6 +84,7 @@ type MockDownloadState = {
guid?: string;
url: string;
suggestedFilename: string;
frameId?: string;
progress?: MockDownloadProgressState[];
};
@@ -253,12 +254,19 @@ type MockInterceptState = {
pauseDispatchDelayMs?: number;
};
type MockFrameTree = {
frame: { id: string };
childFrames?: MockFrameTree[];
};
type MockPageState = {
id: string;
title: string;
currentUrl: string;
fileInputs?: Record<string, MockFileInputPlan>;
browser?: MockBrowserState;
frameTree?: MockFrameTree;
frameTreeSequence?: MockFrameTree[];
readyStateSequence?: string[];
visibleText?: string;
domSnapshot?: string;
@@ -781,6 +789,7 @@ function createMockBrowser(pagesInput: MockPageState[]) {
browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || [];
browserState.canceledDownloadGuids.push(String(message.params?.guid || ''));
reply({});
return;
}
});
@@ -792,7 +801,7 @@ function createMockBrowser(pagesInput: MockPageState[]) {
JSON.stringify({
method: 'Browser.downloadWillBegin',
params: {
frameId: `frame-${page.id}`,
frameId: download.frameId || `frame-${page.id}`,
guid,
url: download.url,
suggestedFilename: download.suggestedFilename,
@@ -865,6 +874,12 @@ function createMockBrowser(pagesInput: MockPageState[]) {
return;
}
if (message.method === 'Page.getFrameTree') {
const nextFrameTree = page.frameTreeSequence?.shift();
reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } });
return;
}
if (message.method === 'Page.captureScreenshot') {
if (!page.screenshot) {
reply({ data: '' });
@@ -8401,6 +8416,167 @@ describe('ccs-browser MCP server', () => {
expect(text).not.toContain('embedded-checkout');
});
it('filters download events by the selected page frame when pageIndex is provided', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'First Page',
currentUrl: 'https://example.com/one',
events: {
downloads: [
{
url: 'https://example.com/first-report.csv',
suggestedFilename: 'first-report.csv',
frameId: 'frame-page-1',
},
],
},
},
{
id: 'page-2',
title: 'Second Page',
currentUrl: 'https://example.com/two',
events: {
downloads: [
{
url: 'https://example.com/second-report.csv',
suggestedFilename: 'second-report.csv',
frameId: 'frame-page-2',
},
],
},
},
],
[
{
jsonrpc: '2.0',
id: 59,
method: 'tools/call',
params: {
name: 'browser_wait_for_event',
arguments: {
pageIndex: 1,
timeoutMs: 1000,
event: { kind: 'download', suggestedFilenameIncludes: 'second-report' },
},
},
},
]
);
const text = getResponseText(responses.find((message) => message.id === 59));
expect(text).toContain('status: observed');
expect(text).toContain('"suggestedFilename":"second-report.csv"');
expect(text).not.toContain('first-report.csv');
});
it('matches download events from child frames within the selected page', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Framed Page',
currentUrl: 'https://example.com/frame-host',
frameTree: {
frame: { id: 'frame-page-1' },
childFrames: [{ frame: { id: 'frame-page-1-child' } }],
},
events: {
downloads: [
{
url: 'https://example.com/embedded-report.csv',
suggestedFilename: 'embedded-report.csv',
frameId: 'frame-page-1-child',
},
],
},
},
{
id: 'page-2',
title: 'Other Page',
currentUrl: 'https://example.com/other',
events: {
downloads: [
{
url: 'https://example.com/other-report.csv',
suggestedFilename: 'other-report.csv',
frameId: 'frame-page-2',
},
],
},
},
],
[
{
jsonrpc: '2.0',
id: 60,
method: 'tools/call',
params: {
name: 'browser_wait_for_event',
arguments: {
pageIndex: 0,
timeoutMs: 1000,
event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' },
},
},
},
]
);
const text = getResponseText(responses.find((message) => message.id === 60));
expect(text).toContain('status: observed');
expect(text).toContain('"suggestedFilename":"embedded-report.csv"');
expect(text).not.toContain('other-report.csv');
});
it('refreshes frame ids when a download comes from a newly attached child frame', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Dynamic Frame Page',
currentUrl: 'https://example.com/dynamic',
frameTreeSequence: [
{ frame: { id: 'frame-page-1' } },
{
frame: { id: 'frame-page-1' },
childFrames: [{ frame: { id: 'frame-page-1-late-child' } }],
},
],
events: {
downloads: [
{
url: 'https://example.com/late-report.csv',
suggestedFilename: 'late-report.csv',
frameId: 'frame-page-1-late-child',
},
],
},
},
],
[
{
jsonrpc: '2.0',
id: 61,
method: 'tools/call',
params: {
name: 'browser_wait_for_event',
arguments: {
pageIndex: 0,
timeoutMs: 1000,
event: { kind: 'download', suggestedFilenameIncludes: 'late-report' },
},
},
},
]
);
const text = getResponseText(responses.find((message) => message.id === 61));
expect(text).toContain('status: observed');
expect(text).toContain('"suggestedFilename":"late-report.csv"');
});
it('captures element screenshots using the post-scroll visible clip and reports failures', async () => {
const screenshotPlan: MockPageState['screenshot'] = {
data: 'ZWxlbWVudC1zaG90',