From 1833ed3ad612c290226208e6557eacb282bfd6b2 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 21:03:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=E8=B7=A8=20?= =?UTF-8?q?page=20orchestration=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增显式跨页 orchestration block,支持开页、切页、关页后执行单页逻辑。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 44 +++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 258 ++++++++++++++++++ 2 files changed, 302 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 90dd5812..a26d9ce1 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2638,6 +2638,15 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ 'run_replay_sequence', 'assert_query', 'sequence', + 'open_page_then_run', + 'select_page_then_run', + 'close_page_then_continue', +]); + +const CROSS_PAGE_BLOCK_TYPES = new Set([ + 'open_page_then_run', + 'select_page_then_run', + 'close_page_then_continue', ]); const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']); @@ -2805,6 +2814,16 @@ function validateSequenceStep(step) { } } +function requireCrossPageRunBlock(block) { + if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') { + throw new Error('cross-page run block is required'); + } + if (CROSS_PAGE_BLOCK_TYPES.has(block.args.run.type)) { + throw new Error('nested cross-page blocks are not supported'); + } + return block.args.run; +} + function normalizeRecordedEvent(pageId, event) { if (!event || typeof event !== 'object') { return null; @@ -5208,6 +5227,31 @@ async function handleCancelReplay() { } async function executeOrchestrationBlock(session, block) { + if (block.type === 'select_page_then_run') { + const selectResult = await handleSelectPage(block.args.select || {}); + const match = /pageId: (.+)/.exec(selectResult); + const selectedPageId = match?.[1]?.trim() || session.pageId; + const runBlock = requireCrossPageRunBlock(block); + const childSession = { ...session, pageId: selectedPageId }; + await executeOrchestrationBlock(childSession, runBlock); + return; + } + + if (block.type === 'open_page_then_run') { + const openResult = await handleOpenPage(block.args.open || {}); + const match = /pageId: (.+)/.exec(openResult); + const openedPageId = match?.[1]?.trim() || session.pageId; + const runBlock = requireCrossPageRunBlock(block); + const childSession = { ...session, pageId: openedPageId }; + await executeOrchestrationBlock(childSession, runBlock); + return; + } + + if (block.type === 'close_page_then_continue') { + await handleClosePage(block.args.close || {}); + return; + } + if (block.type === 'wait_for_then_click') { await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); await handleClick({ pageId: session.pageId, ...block.args.click }); diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 60fc5493..fdfec7a0 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -7511,6 +7511,264 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); }); + it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1701, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 1 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); + }); + + it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1702, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'open_page_then_run', + args: { + open: { url: 'https://example.com/docs' }, + run: { + type: 'wait_for_then_press_key', + args: { + wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, + pressKey: { key: 'Enter' }, + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); + }); + + it('runs close_page_then_continue and then continues with the remaining blocks', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, + { + jsonrpc: '2.0', + id: 1704, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'close_page_then_continue', + args: { close: { pageId: 'page-2' } }, + }), + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + continueOnError: true, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); + }); + + it('rejects missing run payloads and nested cross-page blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1705, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1706, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: createOrchestrationBlock({ + type: 'open_page_then_run', + args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, + }), + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); + expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); + }); + + it('fails when select_page_then_run targets a missing page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1707, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageId: 'page-9' }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1708, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1708)); + expect(text).toContain('status: failed'); + expect(text).toContain('page not found: page-9'); + }); + it('drags local files onto normal, frame, and shadow dropzones', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); const invoicePath = join(tempDir, 'invoice.pdf');