mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(browser): 增加跨 page orchestration 能力
新增显式跨页 orchestration block,支持开页、切页、关页后执行单页逻辑。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3c791a467f
commit
1833ed3ad6
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user