mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
feat(browser): 增加 sequence 组合块能力
新增单层 sequence 容器以组合现有 orchestration blocks,并补充 sequence 内失败定位能力。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
26baa019e3
commit
0038b063ac
2 files changed
+374
No files matched your search
@@ -2438,6 +2438,9 @@ function formatOrchestrationSummary(session) {
|
|||||||
`error: ${session.error || 'none'}`,
|
`error: ${session.error || 'none'}`,
|
||||||
`status: ${session.status}`,
|
`status: ${session.status}`,
|
||||||
];
|
];
|
||||||
|
if (session.failedSequenceStepIndex !== null && session.failedSequenceStepIndex !== undefined) {
|
||||||
|
lines.push(`failedSequenceStepIndex: ${session.failedSequenceStepIndex}`);
|
||||||
|
}
|
||||||
if (session.errorDetails?.failedAssertionIndex !== undefined) {
|
if (session.errorDetails?.failedAssertionIndex !== undefined) {
|
||||||
lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`);
|
lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`);
|
||||||
lines.push(`field: ${session.errorDetails.field}`);
|
lines.push(`field: ${session.errorDetails.field}`);
|
||||||
@@ -2523,6 +2526,7 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([
|
|||||||
'wait_for_then_press_key',
|
'wait_for_then_press_key',
|
||||||
'run_replay_sequence',
|
'run_replay_sequence',
|
||||||
'assert_query',
|
'assert_query',
|
||||||
|
'sequence',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']);
|
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) {
|
function normalizeRecordedEvent(pageId, event) {
|
||||||
if (!event || typeof event !== 'object') {
|
if (!event || typeof event !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
@@ -5116,9 +5135,28 @@ async function executeOrchestrationBlock(session, block) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (block.type === 'sequence') {
|
||||||
|
await executeSequenceSteps(session, block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error('unsupported orchestration block type');
|
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) {
|
async function executeOrchestrationBlocks(session) {
|
||||||
for (let index = 0; index < session.blocks.length; index += 1) {
|
for (let index = 0; index < session.blocks.length; index += 1) {
|
||||||
const block = session.blocks[index];
|
const block = session.blocks[index];
|
||||||
@@ -5154,6 +5192,7 @@ async function handleStartOrchestration(toolArgs) {
|
|||||||
completedBlocks: 0,
|
completedBlocks: 0,
|
||||||
currentBlockIndex: 0,
|
currentBlockIndex: 0,
|
||||||
failedBlockIndex: null,
|
failedBlockIndex: null,
|
||||||
|
failedSequenceStepIndex: null,
|
||||||
error: null,
|
error: null,
|
||||||
blocks,
|
blocks,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6410,6 +6410,341 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(text).toContain('failedBlockIndex: 0');
|
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 () => {
|
it('passes assert_query with multiple structured assertions', async () => {
|
||||||
const responses = await runMcpRequests(
|
const responses = await runMcpRequests(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in new issue
Block a user