diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a26d9ce1..d4339369 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -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 }); } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index fdfec7a0..97fa747d 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -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; 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',