feat(browser): 增加跨 page orchestration 能力

新增显式跨页 orchestration block,支持开页、切页、关页后执行单页逻辑。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-20 21:03:33 +08:00
co-authored by Claude Opus 4.7
parent 3c791a467f
commit 1833ed3ad6
2 changed files with 302 additions and 0 deletions
+44
View File
@@ -2638,6 +2638,15 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([
'run_replay_sequence',
'assert_query',
'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']);
@@ -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) {
if (!event || typeof event !== 'object') {
return null;
@@ -5208,6 +5227,31 @@ async function handleCancelReplay() {
}
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') {
await handleWaitFor({ pageId: session.pageId, ...block.args.wait });
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');
});
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 () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
const invoicePath = join(tempDir, 'invoice.pdf');