diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a61cd54d..0ed628a7 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2438,6 +2438,9 @@ function formatOrchestrationSummary(session) { `error: ${session.error || 'none'}`, `status: ${session.status}`, ]; + if (session.failedSequenceStepIndex !== null && session.failedSequenceStepIndex !== undefined) { + lines.push(`failedSequenceStepIndex: ${session.failedSequenceStepIndex}`); + } if (session.errorDetails?.failedAssertionIndex !== undefined) { lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`); lines.push(`field: ${session.errorDetails.field}`); @@ -2523,6 +2526,7 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ 'wait_for_then_press_key', 'run_replay_sequence', 'assert_query', + 'sequence', ]); const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']); @@ -2669,6 +2673,21 @@ function validateOrchestrationBlock(block) { } } +function requireSequenceSteps(block) { + const steps = Array.isArray(block.args?.steps) ? block.args.steps : null; + if (!steps || steps.length === 0) { + throw new Error('sequence steps must be a non-empty array'); + } + return steps; +} + +function validateSequenceStep(step) { + validateOrchestrationBlock(step); + if (step.type === 'sequence') { + throw new Error('sequence does not support nested sequence blocks'); + } +} + function normalizeRecordedEvent(pageId, event) { if (!event || typeof event !== 'object') { return null; @@ -5116,9 +5135,28 @@ async function executeOrchestrationBlock(session, block) { return; } + if (block.type === 'sequence') { + await executeSequenceSteps(session, block); + return; + } + throw new Error('unsupported orchestration block type'); } +async function executeSequenceSteps(session, block) { + const steps = requireSequenceSteps(block); + for (let index = 0; index < steps.length; index += 1) { + const step = steps[index]; + validateSequenceStep(step); + try { + await executeOrchestrationBlock(session, step); + } catch (error) { + session.failedSequenceStepIndex = index; + throw error; + } + } +} + async function executeOrchestrationBlocks(session) { for (let index = 0; index < session.blocks.length; index += 1) { const block = session.blocks[index]; @@ -5154,6 +5192,7 @@ async function handleStartOrchestration(toolArgs) { completedBlocks: 0, currentBlockIndex: 0, failedBlockIndex: null, + failedSequenceStepIndex: null, error: null, blocks, }; diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index fa7a42a1..ca1ee265 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -6410,6 +6410,341 @@ describe('ccs-browser MCP server', () => { expect(text).toContain('failedBlockIndex: 0'); }); + it('runs a single-layer sequence with two successful blocks', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Page', + currentUrl: 'https://example.com/sequence', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#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: 1401, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1'); + }); + + it('reports failedSequenceStepIndex when a sequence step fails', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Failure Page', + currentUrl: 'https://example.com/sequence-failure', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1402, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1403, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1403)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedSequenceStepIndex: 1'); + }); + + it('rejects empty sequence steps and nested sequence blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }], + [ + { + jsonrpc: '2.0', + id: 1404, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1405, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks'); + }); + + it('runs a top-level block followed by a sequence in order', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Mixed Sequence Page', + currentUrl: 'https://example.com/mixed-sequence', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + 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: 1406, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2'); + }); + it('passes assert_query with multiple structured assertions', async () => { const responses = await runMcpRequests( [