mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 00:14:15 +00:00
feat(browser): 增加跨 page orchestration 能力
新增显式跨页 orchestration block,支持开页、切页、关页后执行单页逻辑。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c791a467f
commit
1833ed3ad6
2 files changed
+302
No files matched your search
@@ -2638,6 +2638,15 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([
|
|||||||
'run_replay_sequence',
|
'run_replay_sequence',
|
||||||
'assert_query',
|
'assert_query',
|
||||||
'sequence',
|
'sequence',
|
||||||
|
'open_page_then_run',
|
||||||
|
'select_page_then_run',
|
||||||
|
'close_page_then_continue',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CROSS_PAGE_BLOCK_TYPES = new Set([
|
||||||
|
'open_page_then_run',
|
||||||
|
'select_page_then_run',
|
||||||
|
'close_page_then_continue',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']);
|
const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']);
|
||||||
@@ -2805,6 +2814,16 @@ function validateSequenceStep(step) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireCrossPageRunBlock(block) {
|
||||||
|
if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') {
|
||||||
|
throw new Error('cross-page run block is required');
|
||||||
|
}
|
||||||
|
if (CROSS_PAGE_BLOCK_TYPES.has(block.args.run.type)) {
|
||||||
|
throw new Error('nested cross-page blocks are not supported');
|
||||||
|
}
|
||||||
|
return block.args.run;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRecordedEvent(pageId, event) {
|
function normalizeRecordedEvent(pageId, event) {
|
||||||
if (!event || typeof event !== 'object') {
|
if (!event || typeof event !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
@@ -5208,6 +5227,31 @@ async function handleCancelReplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeOrchestrationBlock(session, block) {
|
async function executeOrchestrationBlock(session, block) {
|
||||||
|
if (block.type === 'select_page_then_run') {
|
||||||
|
const selectResult = await handleSelectPage(block.args.select || {});
|
||||||
|
const match = /pageId: (.+)/.exec(selectResult);
|
||||||
|
const selectedPageId = match?.[1]?.trim() || session.pageId;
|
||||||
|
const runBlock = requireCrossPageRunBlock(block);
|
||||||
|
const childSession = { ...session, pageId: selectedPageId };
|
||||||
|
await executeOrchestrationBlock(childSession, runBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'open_page_then_run') {
|
||||||
|
const openResult = await handleOpenPage(block.args.open || {});
|
||||||
|
const match = /pageId: (.+)/.exec(openResult);
|
||||||
|
const openedPageId = match?.[1]?.trim() || session.pageId;
|
||||||
|
const runBlock = requireCrossPageRunBlock(block);
|
||||||
|
const childSession = { ...session, pageId: openedPageId };
|
||||||
|
await executeOrchestrationBlock(childSession, runBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'close_page_then_continue') {
|
||||||
|
await handleClosePage(block.args.close || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (block.type === 'wait_for_then_click') {
|
if (block.type === 'wait_for_then_click') {
|
||||||
await handleWaitFor({ pageId: session.pageId, ...block.args.wait });
|
await handleWaitFor({ pageId: session.pageId, ...block.args.wait });
|
||||||
await handleClick({ pageId: session.pageId, ...block.args.click });
|
await handleClick({ pageId: session.pageId, ...block.args.click });
|
||||||
|
|||||||
@@ -7511,6 +7511,264 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found');
|
expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
it('drags local files onto normal, frame, and shadow dropzones', async () => {
|
it('drags local files onto normal, frame, and shadow dropzones', async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
||||||
const invoicePath = join(tempDir, 'invoice.pdf');
|
const invoicePath = join(tempDir, 'invoice.pdf');
|
||||||
|
|||||||
Reference in new issue
Block a user