mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(browser): 增加 sequence 组合块能力
新增单层 sequence 容器以组合现有 orchestration blocks,并补充 sequence 内失败定位能力。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
26baa019e3
commit
0038b063ac
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user