mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +00:00
feat(browser): 完成 Phase 10B replay 能力
新增最小 replay 闭环,支持消费 10A 结构化 steps 并顺序回放常见动作。 同时补充输入校验、失败定位与 replay 边界覆盖。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
b477a75d0e
commit
9d21e83d46
2 files changed
+591
No files matched your search
@@ -66,6 +66,9 @@ const TOOL_START_RECORDING = 'browser_start_recording';
|
|||||||
const TOOL_STOP_RECORDING = 'browser_stop_recording';
|
const TOOL_STOP_RECORDING = 'browser_stop_recording';
|
||||||
const TOOL_GET_RECORDING = 'browser_get_recording';
|
const TOOL_GET_RECORDING = 'browser_get_recording';
|
||||||
const TOOL_CLEAR_RECORDING = 'browser_clear_recording';
|
const TOOL_CLEAR_RECORDING = 'browser_clear_recording';
|
||||||
|
const TOOL_START_REPLAY = 'browser_start_replay';
|
||||||
|
const TOOL_GET_REPLAY = 'browser_get_replay';
|
||||||
|
const TOOL_CANCEL_REPLAY = 'browser_cancel_replay';
|
||||||
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
||||||
const TOOL_WAIT_FOR = 'browser_wait_for';
|
const TOOL_WAIT_FOR = 'browser_wait_for';
|
||||||
const TOOL_EVAL = 'browser_eval';
|
const TOOL_EVAL = 'browser_eval';
|
||||||
@@ -101,6 +104,9 @@ const TOOL_NAMES = [
|
|||||||
TOOL_STOP_RECORDING,
|
TOOL_STOP_RECORDING,
|
||||||
TOOL_GET_RECORDING,
|
TOOL_GET_RECORDING,
|
||||||
TOOL_CLEAR_RECORDING,
|
TOOL_CLEAR_RECORDING,
|
||||||
|
TOOL_START_REPLAY,
|
||||||
|
TOOL_GET_REPLAY,
|
||||||
|
TOOL_CANCEL_REPLAY,
|
||||||
TOOL_TAKE_SCREENSHOT,
|
TOOL_TAKE_SCREENSHOT,
|
||||||
TOOL_WAIT_FOR,
|
TOOL_WAIT_FOR,
|
||||||
TOOL_EVAL,
|
TOOL_EVAL,
|
||||||
@@ -139,6 +145,9 @@ let nextDownloadCounter = 1;
|
|||||||
let nextRecordingCounter = 1;
|
let nextRecordingCounter = 1;
|
||||||
let activeRecordingSession = null;
|
let activeRecordingSession = null;
|
||||||
let latestRecordingSession = null;
|
let latestRecordingSession = null;
|
||||||
|
let nextReplayCounter = 1;
|
||||||
|
let activeReplaySession = null;
|
||||||
|
let latestReplaySession = null;
|
||||||
const interceptRules = [];
|
const interceptRules = [];
|
||||||
const recentRequests = [];
|
const recentRequests = [];
|
||||||
const recentDownloads = [];
|
const recentDownloads = [];
|
||||||
@@ -759,6 +768,38 @@ function getTools() {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_START_REPLAY,
|
||||||
|
description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
steps: { type: 'array', items: { type: 'object' } },
|
||||||
|
pageIndex: { type: 'integer', minimum: 0 },
|
||||||
|
pageId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['steps'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_GET_REPLAY,
|
||||||
|
description: 'Read the current replay status and result summary from session-local state.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_CANCEL_REPLAY,
|
||||||
|
description: 'Cancel the active replay session and keep the summary in session-local state.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: TOOL_TAKE_SCREENSHOT,
|
name: TOOL_TAKE_SCREENSHOT,
|
||||||
description:
|
description:
|
||||||
@@ -2286,6 +2327,12 @@ function createRecordingId() {
|
|||||||
return recordingId;
|
return recordingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createReplayId() {
|
||||||
|
const replayId = `rep_${String(nextReplayCounter).padStart(4, '0')}`;
|
||||||
|
nextReplayCounter += 1;
|
||||||
|
return replayId;
|
||||||
|
}
|
||||||
|
|
||||||
function requireLatestRecording() {
|
function requireLatestRecording() {
|
||||||
if (!latestRecordingSession) {
|
if (!latestRecordingSession) {
|
||||||
throw new Error('no recording available');
|
throw new Error('no recording available');
|
||||||
@@ -2304,6 +2351,27 @@ function formatRecordingSummary(session) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireLatestReplay() {
|
||||||
|
if (!latestReplaySession) {
|
||||||
|
throw new Error('no replay available');
|
||||||
|
}
|
||||||
|
return latestReplaySession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReplaySummary(session) {
|
||||||
|
return [
|
||||||
|
`replayId: ${session.replayId}`,
|
||||||
|
`pageId: ${session.pageId}`,
|
||||||
|
`pageIndex: ${session.pageIndex}`,
|
||||||
|
`stepCount: ${session.stepCount}`,
|
||||||
|
`completedSteps: ${session.completedSteps}`,
|
||||||
|
`currentStepIndex: ${session.currentStepIndex}`,
|
||||||
|
`failedStepIndex: ${session.failedStepIndex === null ? 'none' : session.failedStepIndex}`,
|
||||||
|
`error: ${session.error || 'none'}`,
|
||||||
|
`status: ${session.status}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function formatRecordingDetail(session) {
|
function formatRecordingDetail(session) {
|
||||||
const lines = [formatRecordingSummary(session), 'steps:'];
|
const lines = [formatRecordingSummary(session), 'steps:'];
|
||||||
if (session.steps.length === 0) {
|
if (session.steps.length === 0) {
|
||||||
@@ -2344,6 +2412,35 @@ function formatRecordingDetail(session) {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_REPLAY_STEP_TYPES = new Set([
|
||||||
|
'click',
|
||||||
|
'type',
|
||||||
|
'press_key',
|
||||||
|
'scroll',
|
||||||
|
'drag_element',
|
||||||
|
'pointer_action',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function requireReplaySteps(toolArgs) {
|
||||||
|
const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps : null;
|
||||||
|
if (!steps || steps.length === 0) {
|
||||||
|
throw new Error('steps must be a non-empty array');
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReplayStep(step, replayPageId) {
|
||||||
|
if (!step || typeof step !== 'object') {
|
||||||
|
throw new Error('invalid replay step payload');
|
||||||
|
}
|
||||||
|
if (!SUPPORTED_REPLAY_STEP_TYPES.has(step.type)) {
|
||||||
|
throw new Error('unsupported replay step type');
|
||||||
|
}
|
||||||
|
if (step.pageId && step.pageId !== replayPageId) {
|
||||||
|
throw new Error('replay step pageId mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRecordedEvent(pageId, event) {
|
function normalizeRecordedEvent(pageId, event) {
|
||||||
if (!event || typeof event !== 'object') {
|
if (!event || typeof event !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
@@ -4560,6 +4657,192 @@ async function handleClearRecording() {
|
|||||||
return 'status: cleared';
|
return 'status: cleared';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReplayToolArgs(step, replayPage) {
|
||||||
|
const baseArgs = {
|
||||||
|
pageId: replayPage.pageId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step.type === 'click') {
|
||||||
|
return {
|
||||||
|
toolName: TOOL_CLICK,
|
||||||
|
toolArgs: {
|
||||||
|
...baseArgs,
|
||||||
|
selector: requireNonEmptyString(step.selector, 'selector'),
|
||||||
|
nth: step.nth,
|
||||||
|
frameSelector: step.frameSelector,
|
||||||
|
pierceShadow: step.pierceShadow,
|
||||||
|
button: step.args?.button,
|
||||||
|
clickCount: step.args?.clickCount,
|
||||||
|
offsetX: step.args?.offsetX,
|
||||||
|
offsetY: step.args?.offsetY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'type') {
|
||||||
|
return {
|
||||||
|
toolName: TOOL_TYPE,
|
||||||
|
toolArgs: {
|
||||||
|
...baseArgs,
|
||||||
|
selector: requireNonEmptyString(step.selector, 'selector'),
|
||||||
|
nth: step.nth,
|
||||||
|
frameSelector: step.frameSelector,
|
||||||
|
pierceShadow: step.pierceShadow,
|
||||||
|
text: requireString(step.args?.text, 'text'),
|
||||||
|
clearFirst: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'press_key') {
|
||||||
|
return {
|
||||||
|
toolName: TOOL_PRESS_KEY,
|
||||||
|
toolArgs: {
|
||||||
|
...baseArgs,
|
||||||
|
key: requireNonEmptyString(step.args?.key, 'key'),
|
||||||
|
modifiers: Array.isArray(step.args?.modifiers) ? step.args.modifiers : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'scroll') {
|
||||||
|
return {
|
||||||
|
toolName: TOOL_SCROLL,
|
||||||
|
toolArgs: {
|
||||||
|
...baseArgs,
|
||||||
|
selector: step.selector,
|
||||||
|
frameSelector: step.frameSelector,
|
||||||
|
pierceShadow: step.pierceShadow,
|
||||||
|
behavior: 'by-offset',
|
||||||
|
deltaX: step.args?.deltaX ?? 0,
|
||||||
|
deltaY: step.args?.deltaY ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'drag_element') {
|
||||||
|
return {
|
||||||
|
toolName: TOOL_DRAG_ELEMENT,
|
||||||
|
toolArgs: {
|
||||||
|
...baseArgs,
|
||||||
|
selector: requireNonEmptyString(step.selector, 'selector'),
|
||||||
|
nth: step.nth,
|
||||||
|
frameSelector: step.frameSelector,
|
||||||
|
pierceShadow: step.pierceShadow,
|
||||||
|
...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}),
|
||||||
|
...(step.args?.targetNth !== undefined ? { targetNth: step.args.targetNth } : {}),
|
||||||
|
...(step.args?.targetX !== undefined ? { targetX: step.args.targetX } : {}),
|
||||||
|
...(step.args?.targetY !== undefined ? { targetY: step.args.targetY } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'pointer_action') {
|
||||||
|
return {
|
||||||
|
toolName: TOOL_POINTER_ACTION,
|
||||||
|
toolArgs: {
|
||||||
|
...baseArgs,
|
||||||
|
actions: Array.isArray(step.args?.actions) ? step.args.actions : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeReplaySteps(session) {
|
||||||
|
for (let index = 0; index < session.steps.length; index += 1) {
|
||||||
|
const step = session.steps[index];
|
||||||
|
session.currentStepIndex = index;
|
||||||
|
validateReplayStep(step, session.pageId);
|
||||||
|
|
||||||
|
const mapped = buildReplayToolArgs(step, session);
|
||||||
|
if (!mapped) {
|
||||||
|
throw new Error('unsupported replay step type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapped.toolName === TOOL_CLICK) {
|
||||||
|
await handleClick(mapped.toolArgs);
|
||||||
|
} else if (mapped.toolName === TOOL_TYPE) {
|
||||||
|
await handleType(mapped.toolArgs);
|
||||||
|
} else if (mapped.toolName === TOOL_PRESS_KEY) {
|
||||||
|
await handlePressKey(mapped.toolArgs);
|
||||||
|
} else if (mapped.toolName === TOOL_SCROLL) {
|
||||||
|
await handleScroll(mapped.toolArgs);
|
||||||
|
} else if (mapped.toolName === TOOL_DRAG_ELEMENT) {
|
||||||
|
await handleDragElement(mapped.toolArgs);
|
||||||
|
} else if (mapped.toolName === TOOL_POINTER_ACTION) {
|
||||||
|
await handlePointerAction(mapped.toolArgs);
|
||||||
|
} else {
|
||||||
|
throw new Error('unsupported replay step type');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.completedSteps = index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartReplay(toolArgs) {
|
||||||
|
if (activeReplaySession) {
|
||||||
|
throw new Error('replay already active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await listPageTargets();
|
||||||
|
if (pages.length === 0) {
|
||||||
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
||||||
|
if (!page.webSocketDebuggerUrl) {
|
||||||
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = requireReplaySteps(toolArgs);
|
||||||
|
const session = {
|
||||||
|
replayId: createReplayId(),
|
||||||
|
pageId: page.id,
|
||||||
|
pageIndex,
|
||||||
|
status: 'running',
|
||||||
|
stepCount: steps.length,
|
||||||
|
completedSteps: 0,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
failedStepIndex: null,
|
||||||
|
error: null,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
latestReplaySession = session;
|
||||||
|
activeReplaySession = session;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeReplaySteps(session);
|
||||||
|
session.status = 'completed';
|
||||||
|
} catch (error) {
|
||||||
|
session.status = 'failed';
|
||||||
|
session.failedStepIndex = session.currentStepIndex;
|
||||||
|
session.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeReplaySession = null;
|
||||||
|
latestReplaySession = session;
|
||||||
|
return formatReplaySummary(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetReplay() {
|
||||||
|
const session = requireLatestReplay();
|
||||||
|
return formatReplaySummary(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancelReplay() {
|
||||||
|
if (!activeReplaySession) {
|
||||||
|
throw new Error('no active replay');
|
||||||
|
}
|
||||||
|
activeReplaySession.status = 'canceled';
|
||||||
|
const canceled = activeReplaySession;
|
||||||
|
activeReplaySession = null;
|
||||||
|
latestReplaySession = canceled;
|
||||||
|
return formatReplaySummary(canceled);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToolCall(message) {
|
async function handleToolCall(message) {
|
||||||
const id = message.id;
|
const id = message.id;
|
||||||
const params = message.params || {};
|
const params = message.params || {};
|
||||||
@@ -4780,6 +5063,30 @@ async function handleToolCall(message) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_START_REPLAY) {
|
||||||
|
const text = await handleStartReplay(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_GET_REPLAY) {
|
||||||
|
const text = await handleGetReplay(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_CANCEL_REPLAY) {
|
||||||
|
const text = await handleCancelReplay(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
||||||
const text = await handleScreenshot(toolArgs);
|
const text = await handleScreenshot(toolArgs);
|
||||||
writeResponse(id, {
|
writeResponse(id, {
|
||||||
|
|||||||
@@ -435,6 +435,10 @@ function getResponseText(message: Record<string, unknown> | undefined): string {
|
|||||||
return result.content?.[0]?.text || '';
|
return result.content?.[0]?.text || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createReplayStep(step: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonArgument(expression: string, key: string): string | undefined {
|
function parseJsonArgument(expression: string, key: string): string | undefined {
|
||||||
const marker = `const ${key} = JSON.parse(`;
|
const marker = `const ${key} = JSON.parse(`;
|
||||||
const start = expression.indexOf(marker);
|
const start = expression.indexOf(marker);
|
||||||
@@ -1829,6 +1833,9 @@ describe('ccs-browser MCP server', () => {
|
|||||||
'browser_stop_recording',
|
'browser_stop_recording',
|
||||||
'browser_get_recording',
|
'browser_get_recording',
|
||||||
'browser_clear_recording',
|
'browser_clear_recording',
|
||||||
|
'browser_start_replay',
|
||||||
|
'browser_get_replay',
|
||||||
|
'browser_cancel_replay',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait_for',
|
'browser_wait_for',
|
||||||
'browser_eval',
|
'browser_eval',
|
||||||
@@ -1950,6 +1957,17 @@ describe('ccs-browser MCP server', () => {
|
|||||||
const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording');
|
const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording');
|
||||||
expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' });
|
expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
|
||||||
|
const startReplayTool = tools.find((tool) => tool.name === 'browser_start_replay');
|
||||||
|
expect(startReplayTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'array' });
|
||||||
|
expect(startReplayTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
|
||||||
|
expect(startReplayTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
|
||||||
|
|
||||||
|
const getReplayTool = tools.find((tool) => tool.name === 'browser_get_replay');
|
||||||
|
expect(getReplayTool?.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
|
||||||
|
const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay');
|
||||||
|
expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
|
||||||
const queryTool = tools.find((tool) => tool.name === 'browser_query');
|
const queryTool = tools.find((tool) => tool.name === 'browser_query');
|
||||||
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -5816,6 +5834,272 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(text).toContain('recording stopped because target page was closed');
|
expect(text).toContain('recording stopped because target page was closed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts a replay, reports progress, and completes basic steps', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Page',
|
||||||
|
currentUrl: 'https://example.com/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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
'#email': { kind: 'input', inputType: 'email', value: '' },
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
'#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1101,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'click',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#submit',
|
||||||
|
nth: 0,
|
||||||
|
args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 },
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'type',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#email',
|
||||||
|
nth: 0,
|
||||||
|
args: { text: 'walker@example.com' },
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'scroll',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#results',
|
||||||
|
args: { deltaX: 0, deltaY: 240 },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1102,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid replay payloads before execution starts', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1111,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_replay', arguments: { steps: [] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1112,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1113,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })],
|
||||||
|
pageId: 'page-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails replay on the first failing step and reports failedStepIndex', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Failure Page',
|
||||||
|
currentUrl: 'https://example.com/replay-failure',
|
||||||
|
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: 1114,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }),
|
||||||
|
createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1115,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1115));
|
||||||
|
expect(text).toContain('status: failed');
|
||||||
|
expect(text).toContain('failedStepIndex: 1');
|
||||||
|
expect(text).toContain('element index 0 is out of range for selector: #missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays drag_element and pointer_action steps successfully', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Drag Page',
|
||||||
|
currentUrl: 'https://example.com/replay-drag',
|
||||||
|
query: {
|
||||||
|
'#card-a': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 40,
|
||||||
|
width: 100,
|
||||||
|
height: 60,
|
||||||
|
top: 40,
|
||||||
|
right: 120,
|
||||||
|
bottom: 100,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'#lane-b': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 240,
|
||||||
|
y: 60,
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
top: 60,
|
||||||
|
right: 360,
|
||||||
|
bottom: 140,
|
||||||
|
left: 240,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1121,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'drag_element',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#card-a',
|
||||||
|
args: { targetSelector: '#lane-b' },
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'pointer_action',
|
||||||
|
pageId: 'page-1',
|
||||||
|
args: {
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', x: 10, y: 20 },
|
||||||
|
{ type: 'down', button: 'left' },
|
||||||
|
{ type: 'up', button: 'left' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2');
|
||||||
|
});
|
||||||
|
|
||||||
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