mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-19 06:18:11 +00:00
1766 lines
53 KiB
TypeScript
1766 lines
53 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import { runMcpRequests, getResponseText, createReplayStep, createOrchestrationBlock, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
|
|
import type { MockPageState } from './browser-mcp-test-harness';
|
|
|
|
describe('ccs-browser MCP server - orchestration and artifacts', () => {
|
|
it('starts orchestration, reports progress, and completes a wait_then_click flow', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Orchestration Page',
|
|
currentUrl: 'https://example.com/orchestration',
|
|
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',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1201,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'wait_for_then_click',
|
|
args: {
|
|
wait: {
|
|
selector: '#menu',
|
|
condition: { kind: 'visibility' },
|
|
timeoutMs: 1000,
|
|
},
|
|
click: {
|
|
selector: '#menu',
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1202,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_get_orchestration', arguments: {} },
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed');
|
|
expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1');
|
|
expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed');
|
|
});
|
|
|
|
it('cancels orchestration before later blocks run', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Orchestration Cancel Page',
|
|
currentUrl: 'https://example.com/orchestration-cancel',
|
|
query: {
|
|
'#handle': {
|
|
exists: true,
|
|
connected: true,
|
|
rect: {
|
|
x: 20,
|
|
y: 30,
|
|
width: 40,
|
|
height: 40,
|
|
top: 30,
|
|
right: 60,
|
|
bottom: 70,
|
|
left: 20,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
'#status': {
|
|
exists: true,
|
|
connected: true,
|
|
innerText: 'ready',
|
|
textContent: 'ready',
|
|
rect: {
|
|
x: 80,
|
|
y: 30,
|
|
width: 80,
|
|
height: 20,
|
|
top: 30,
|
|
right: 160,
|
|
bottom: 50,
|
|
left: 80,
|
|
},
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1202_1,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'run_replay_sequence',
|
|
args: {
|
|
steps: [
|
|
createReplayStep({
|
|
type: 'pointer_action',
|
|
pageId: 'page-1',
|
|
args: {
|
|
actions: [
|
|
{ type: 'move', selector: '#handle' },
|
|
{ type: 'pause', durationMs: 400 },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
}),
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['innerText'] },
|
|
assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1202_2,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_cancel_orchestration', arguments: {} },
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1202_3,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_get_orchestration', arguments: {} },
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain(
|
|
'status: running'
|
|
);
|
|
const text = getResponseText(responses.find((message) => message.id === 1202_3));
|
|
expect(text).toContain('status: canceled');
|
|
expect(text).not.toContain('completedBlocks: 2');
|
|
});
|
|
|
|
it('completes a wait_then_type flow', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Type Orchestration Page',
|
|
currentUrl: 'https://example.com/type-orchestration',
|
|
wait: {
|
|
selectorSnapshots: {
|
|
'#email': [
|
|
[
|
|
{
|
|
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',
|
|
},
|
|
],
|
|
],
|
|
},
|
|
},
|
|
type: {
|
|
'#email': { kind: 'input', inputType: 'email', value: '' },
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1203,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'wait_for_then_type',
|
|
args: {
|
|
wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 },
|
|
type: { selector: '#email', text: 'walker@example.com', clearFirst: true },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed');
|
|
expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1');
|
|
});
|
|
|
|
it('completes a run_replay_sequence block', async () => {
|
|
const pages: MockPageState[] = [
|
|
{
|
|
id: 'page-1',
|
|
title: 'Replay Sequence Orchestration Page',
|
|
currentUrl: 'https://example.com/orchestration-replay',
|
|
click: { '#submit': {} },
|
|
query: {
|
|
'#submit': {
|
|
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',
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const responses = await runMcpRequests(pages, [
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1211,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'run_replay_sequence',
|
|
args: {
|
|
steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed');
|
|
});
|
|
|
|
it('completes an assert_query block', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Assert Query Page',
|
|
currentUrl: 'https://example.com/assert-query',
|
|
query: {
|
|
'#status': {
|
|
exists: true,
|
|
connected: true,
|
|
innerText: 'ready',
|
|
textContent: 'ready',
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '1',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1212,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['innerText'] },
|
|
assert: { field: 'innerText', equals: 'ready' },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed');
|
|
});
|
|
|
|
it('fails orchestration on the first failing block', async () => {
|
|
const responses = await runMcpRequests(
|
|
[{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1213,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#missing', fields: ['exists'] },
|
|
assert: { field: 'exists', equals: 'true' },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1214,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_get_orchestration', arguments: {} },
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 1214));
|
|
expect(text).toContain('status: failed');
|
|
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(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Assert Query Page',
|
|
currentUrl: 'https://example.com/assert-query',
|
|
query: {
|
|
'#status': {
|
|
exists: true,
|
|
connected: true,
|
|
innerText: 'ready state',
|
|
textContent: 'ready state',
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '0.95',
|
|
rect: {
|
|
x: 20,
|
|
y: 30,
|
|
width: 100,
|
|
height: 40,
|
|
top: 30,
|
|
right: 120,
|
|
bottom: 70,
|
|
left: 20,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1301,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['exists', 'innerText', 'opacity'] },
|
|
assertions: [
|
|
{ field: 'exists', op: 'equals', value: true },
|
|
{ field: 'innerText', op: 'contains', value: 'ready' },
|
|
{ field: 'opacity', op: 'gte', value: 0.9 },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1301))).toContain('status: completed');
|
|
});
|
|
|
|
it('returns failedAssertionIndex, expected, and actual when assert_query fails', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Assert Failure Page',
|
|
currentUrl: 'https://example.com/assert-failure',
|
|
query: {
|
|
'#status': {
|
|
exists: true,
|
|
connected: true,
|
|
innerText: 'loading',
|
|
textContent: 'loading',
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
opacity: '0.42',
|
|
rect: {
|
|
x: 20,
|
|
y: 30,
|
|
width: 100,
|
|
height: 40,
|
|
top: 30,
|
|
right: 120,
|
|
bottom: 70,
|
|
left: 20,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1302,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['innerText', 'opacity'] },
|
|
assertions: [
|
|
{ field: 'innerText', op: 'contains', value: 'ready' },
|
|
{ field: 'opacity', op: 'gte', value: 0.9 },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1303,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_get_orchestration', arguments: {} },
|
|
},
|
|
]
|
|
);
|
|
|
|
const text = getResponseText(responses.find((message) => message.id === 1303));
|
|
expect(text).toContain('status: failed');
|
|
expect(text).toContain('failedBlockIndex: 0');
|
|
expect(text).toContain('failedAssertionIndex: 0');
|
|
expect(text).toContain('field: innerText');
|
|
expect(text).toContain('expected: "ready"');
|
|
expect(text).toContain('actual: "loading"');
|
|
});
|
|
|
|
it('fails when numeric comparison is used on a non-number-like field or when op is unsupported', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Assert Type Error Page',
|
|
currentUrl: 'https://example.com/assert-type-error',
|
|
query: {
|
|
'#status': {
|
|
exists: true,
|
|
connected: true,
|
|
innerText: 'loading',
|
|
textContent: 'loading',
|
|
opacity: '0.42',
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
rect: {
|
|
x: 20,
|
|
y: 30,
|
|
width: 100,
|
|
height: 40,
|
|
top: 30,
|
|
right: 120,
|
|
bottom: 70,
|
|
left: 20,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1304,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['innerText'] },
|
|
assertions: [{ field: 'innerText', op: 'gte', value: 1 }],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1305,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['opacity'] },
|
|
assertions: [{ field: 'opacity', op: 'matches', value: '.*' }],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1304))).toContain('numeric comparison expects a number-like field: innerText');
|
|
expect(getResponseText(responses.find((message) => message.id === 1305))).toContain('unsupported assertion operator');
|
|
});
|
|
|
|
it('passes numeric gte and lt assertions', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Numeric Assertion Page',
|
|
currentUrl: 'https://example.com/assert-numeric',
|
|
query: {
|
|
'#status': {
|
|
exists: true,
|
|
connected: true,
|
|
opacity: '0.95',
|
|
display: 'block',
|
|
visibility: 'visible',
|
|
rect: {
|
|
x: 20,
|
|
y: 30,
|
|
width: 100,
|
|
height: 40,
|
|
top: 30,
|
|
right: 120,
|
|
bottom: 70,
|
|
left: 20,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1306,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_orchestration',
|
|
arguments: {
|
|
blocks: [
|
|
createOrchestrationBlock({
|
|
type: 'assert_query',
|
|
args: {
|
|
query: { selector: '#status', fields: ['opacity'] },
|
|
assertions: [
|
|
{ field: 'opacity', op: 'gte', value: 0.9 },
|
|
{ field: 'opacity', op: 'lt', value: 1.0 },
|
|
],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
);
|
|
|
|
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('status: completed_with_failures');
|
|
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('status: completed_with_failures');
|
|
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('status: completed_with_failures');
|
|
expect(text).toContain('completedBlocks: 2');
|
|
expect(text).toContain('failedCount: 1');
|
|
expect(text).toContain('failure[0].sequenceStepIndex: 0');
|
|
});
|
|
|
|
it('exports recording and replay artifacts and lists them', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Artifact Page',
|
|
currentUrl: 'https://example.com/artifact',
|
|
recording: {
|
|
events: [
|
|
{
|
|
kind: 'click',
|
|
selector: '#submit',
|
|
timestamp: 1710000000000,
|
|
},
|
|
],
|
|
},
|
|
click: {
|
|
'#submit': {},
|
|
},
|
|
query: {
|
|
'#submit': {
|
|
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',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{ jsonrpc: '2.0', id: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
|
{ jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1603,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } },
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1604,
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'browser_start_replay',
|
|
arguments: {
|
|
steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1605,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } },
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1606,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_list_artifacts', arguments: {} },
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported');
|
|
expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported');
|
|
const listText = getResponseText(responses.find((message) => message.id === 1606));
|
|
expect(listText).toContain('name: rec-smoke');
|
|
expect(listText).toContain('name: replay-smoke');
|
|
});
|
|
|
|
it('imports and deletes orchestration artifacts', async () => {
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Artifact Page',
|
|
currentUrl: 'https://example.com/artifact',
|
|
click: { '#submit': {} },
|
|
wait: {
|
|
selectorSnapshots: {
|
|
'#submit': [
|
|
[
|
|
{
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1607,
|
|
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' }],
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1608,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } },
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1609,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_list_artifacts', arguments: {} },
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1610,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } },
|
|
},
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1611,
|
|
method: 'tools/call',
|
|
params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } },
|
|
},
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported');
|
|
expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported');
|
|
expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted');
|
|
});
|
|
|
|
it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-'));
|
|
const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json');
|
|
writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a'));
|
|
|
|
const responses = await runMcpRequests(
|
|
[
|
|
{
|
|
id: 'page-1',
|
|
title: 'Artifact Failure Page',
|
|
currentUrl: 'https://example.com/artifact-failure',
|
|
recording: {
|
|
events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }],
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{ jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
|
{ jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
|
{ jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } },
|
|
{ jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } },
|
|
{ jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } },
|
|
{ jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } },
|
|
{ jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } },
|
|
{ jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } },
|
|
{ jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } },
|
|
]
|
|
);
|
|
|
|
expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists');
|
|
expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found');
|
|
expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found');
|
|
expect(getResponseText(responses.find((message) => message.id === 1618))).toContain(
|
|
'artifact name must start with a letter or number'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 1619))).toContain(
|
|
'artifact name must start with a letter or number'
|
|
);
|
|
expect(getResponseText(responses.find((message) => message.id === 1620))).toContain(
|
|
'artifact file exceeds maximum size'
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
});
|