diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index e79e2f52..ea954cdd 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -66,6 +66,9 @@ const TOOL_START_RECORDING = 'browser_start_recording'; const TOOL_STOP_RECORDING = 'browser_stop_recording'; const TOOL_GET_RECORDING = 'browser_get_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_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -101,6 +104,9 @@ const TOOL_NAMES = [ TOOL_STOP_RECORDING, TOOL_GET_RECORDING, TOOL_CLEAR_RECORDING, + TOOL_START_REPLAY, + TOOL_GET_REPLAY, + TOOL_CANCEL_REPLAY, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -139,6 +145,9 @@ let nextDownloadCounter = 1; let nextRecordingCounter = 1; let activeRecordingSession = null; let latestRecordingSession = null; +let nextReplayCounter = 1; +let activeReplaySession = null; +let latestReplaySession = null; const interceptRules = []; const recentRequests = []; const recentDownloads = []; @@ -759,6 +768,38 @@ function getTools() { 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, description: @@ -2286,6 +2327,12 @@ function createRecordingId() { return recordingId; } +function createReplayId() { + const replayId = `rep_${String(nextReplayCounter).padStart(4, '0')}`; + nextReplayCounter += 1; + return replayId; +} + function requireLatestRecording() { if (!latestRecordingSession) { throw new Error('no recording available'); @@ -2304,6 +2351,27 @@ function formatRecordingSummary(session) { ].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) { const lines = [formatRecordingSummary(session), 'steps:']; if (session.steps.length === 0) { @@ -2344,6 +2412,35 @@ function formatRecordingDetail(session) { 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) { if (!event || typeof event !== 'object') { return null; @@ -4560,6 +4657,192 @@ async function handleClearRecording() { 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) { const id = message.id; const params = message.params || {}; @@ -4780,6 +5063,30 @@ async function handleToolCall(message) { 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) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 6c3c0bcf..b01ad4d2 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -435,6 +435,10 @@ function getResponseText(message: Record | undefined): string { return result.content?.[0]?.text || ''; } +function createReplayStep(step: Record): Record { + return step; +} + function parseJsonArgument(expression: string, key: string): string | undefined { const marker = `const ${key} = JSON.parse(`; const start = expression.indexOf(marker); @@ -1829,6 +1833,9 @@ describe('ccs-browser MCP server', () => { 'browser_stop_recording', 'browser_get_recording', 'browser_clear_recording', + 'browser_start_replay', + 'browser_get_replay', + 'browser_cancel_replay', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1950,6 +1957,17 @@ describe('ccs-browser MCP server', () => { const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording'); 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'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -5816,6 +5834,272 @@ describe('ccs-browser MCP server', () => { 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 () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); const invoicePath = join(tempDir, 'invoice.pdf');