diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 0ed628a7..817759d9 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2433,11 +2433,22 @@ function formatOrchestrationSummary(session) { `pageIndex: ${session.pageIndex}`, `blockCount: ${session.blockCount}`, `completedBlocks: ${session.completedBlocks}`, + `failedCount: ${session.failedCount || 0}`, `currentBlockIndex: ${session.currentBlockIndex}`, `failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`, `error: ${session.error || 'none'}`, `status: ${session.status}`, ]; + if (Array.isArray(session.failures) && session.failures.length > 0) { + session.failures.forEach((failure, index) => { + lines.push(`failure[${index}].blockIndex: ${failure.blockIndex}`); + lines.push( + `failure[${index}].sequenceStepIndex: ${failure.sequenceStepIndex === null ? 'none' : failure.sequenceStepIndex}` + ); + lines.push(`failure[${index}].type: ${failure.type}`); + lines.push(`failure[${index}].message: ${failure.message}`); + }); + } if (session.failedSequenceStepIndex !== null && session.failedSequenceStepIndex !== undefined) { lines.push(`failedSequenceStepIndex: ${session.failedSequenceStepIndex}`); } @@ -2656,6 +2667,12 @@ function formatOrchestrationError(error) { return { message }; } +function pushOrchestrationFailure(session, details) { + session.failures = session.failures || []; + session.failures.push(details); + session.failedCount = session.failures.length; +} + function requireOrchestrationBlocks(toolArgs) { const blocks = Array.isArray(toolArgs.blocks) ? toolArgs.blocks : null; if (!blocks || blocks.length === 0) { @@ -5151,7 +5168,17 @@ async function executeSequenceSteps(session, block) { try { await executeOrchestrationBlock(session, step); } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pushOrchestrationFailure(session, { + blockIndex: session.currentBlockIndex, + sequenceStepIndex: index, + type: step.type, + message, + }); session.failedSequenceStepIndex = index; + if (step.continueOnError === true) { + continue; + } throw error; } } @@ -5162,8 +5189,26 @@ async function executeOrchestrationBlocks(session) { const block = session.blocks[index]; session.currentBlockIndex = index; validateOrchestrationBlock(block); - await executeOrchestrationBlock(session, block); - session.completedBlocks = index + 1; + try { + await executeOrchestrationBlock(session, block); + session.completedBlocks = index + 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (session.failedSequenceStepIndex === null || session.failedSequenceStepIndex === undefined) { + pushOrchestrationFailure(session, { + blockIndex: index, + sequenceStepIndex: null, + type: block.type, + message, + }); + } + if (block.continueOnError === true) { + session.failedSequenceStepIndex = null; + session.completedBlocks = index + 1; + continue; + } + throw error; + } } } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index ca1ee265..31618c4a 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -7009,6 +7009,275 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1306))).toContain('status: completed'); }); + it('continues to the next top-level block when continueOnError is true', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Policy Page', + currentUrl: 'https://example.com/policy', + 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, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1501, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1502, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1502)); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].blockIndex: 0'); + }); + + it('continues inside sequence when a step has continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Policy Page', + currentUrl: 'https://example.com/sequence-policy', + 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, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1503, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1504, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1504)); + expect(text).toContain('completedBlocks: 1'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('still stops immediately when continueOnError is not enabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Strict Stop Page', currentUrl: 'https://example.com/strict-stop' }], + [ + { + jsonrpc: '2.0', + id: 1505, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1505)); + expect(text).toContain('status: failed'); + expect(text).toContain('completedBlocks: 0'); + }); + + it('continues to the next top-level block when a sequence block itself allows continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Top-Level Policy Page', + currentUrl: 'https://example.com/sequence-top-policy', + 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, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1506, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + continueOnError: true, + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1507, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1507)); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + 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');