diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 7e80b71b..f0ce6120 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -62,6 +62,10 @@ const TOOL_SET_FILE_INPUT = 'browser_set_file_input'; const TOOL_DRAG_FILES = 'browser_drag_files'; const TOOL_DRAG_ELEMENT = 'browser_drag_element'; const TOOL_POINTER_ACTION = 'browser_pointer_action'; +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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -93,6 +97,10 @@ const TOOL_NAMES = [ TOOL_DRAG_FILES, TOOL_DRAG_ELEMENT, TOOL_POINTER_ACTION, + TOOL_START_RECORDING, + TOOL_STOP_RECORDING, + TOOL_GET_RECORDING, + TOOL_CLEAR_RECORDING, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -128,6 +136,9 @@ let selectedPageId = ''; let messageQueue = Promise.resolve(); let nextInterceptRuleCounter = 1; let nextDownloadCounter = 1; +let nextRecordingCounter = 1; +let activeRecordingSession = null; +let latestRecordingSession = null; const interceptRules = []; const recentRequests = []; const recentDownloads = []; @@ -709,6 +720,45 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_START_RECORDING, + description: 'Start recording real browser interactions on the selected page and store them as structured steps.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_STOP_RECORDING, + description: 'Stop the active browser recording session and keep the recorded result in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_GET_RECORDING, + description: 'Read the current browser recording result from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CLEAR_RECORDING, + description: 'Clear the current browser recording result from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, { name: TOOL_TAKE_SCREENSHOT, description: @@ -2230,6 +2280,162 @@ function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierce return lines.join('\n'); } +function createRecordingId() { + const recordingId = `rec_${String(nextRecordingCounter).padStart(4, '0')}`; + nextRecordingCounter += 1; + return recordingId; +} + +function requireLatestRecording() { + if (!latestRecordingSession) { + throw new Error('no recording available'); + } + return latestRecordingSession; +} + +function formatRecordingSummary(session) { + return [ + `recordingId: ${session.recordingId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `stepCount: ${session.steps.length}`, + `warningCount: ${session.warnings.length}`, + `status: ${session.status}`, + ].join('\n'); +} + +function formatRecordingDetail(session) { + const lines = [formatRecordingSummary(session), 'steps:']; + if (session.steps.length === 0) { + lines.push('[]'); + } else { + session.steps.forEach((step, index) => { + lines.push(`- [${index}] type: ${step.type}`); + lines.push(` pageId: ${step.pageId}`); + lines.push(` timestamp: ${step.timestamp}`); + if (step.selector) { + lines.push(` selector: ${step.selector}`); + } + if (step.frameSelector) { + lines.push(` frameSelector: ${step.frameSelector}`); + } + if (step.pierceShadow === true) { + lines.push(' pierceShadow: true'); + } + if (step.args && typeof step.args === 'object') { + for (const [key, value] of Object.entries(step.args)) { + if (value !== undefined) { + lines.push(` ${key}: ${JSON.stringify(value)}`); + } + } + } + }); + } + + lines.push('warnings:'); + if (session.warnings.length === 0) { + lines.push('[]'); + } else { + session.warnings.forEach((warning) => { + lines.push(`- ${warning}`); + }); + } + + return lines.join('\n'); +} + +function normalizeRecordedEvent(pageId, event) { + if (!event || typeof event !== 'object') { + return null; + } + + const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now(); + if (event.kind === 'click') { + return { + type: 'click', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + button: event.button || 'left', + clickCount: event.clickCount || 1, + offsetX: event.offsetX, + offsetY: event.offsetY, + }, + }; + } + if (event.kind === 'type') { + return { + type: 'type', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + text: event.text || '', + }, + }; + } + if (event.kind === 'press_key') { + return { + type: 'press_key', + pageId, + timestamp, + args: { + key: event.key, + modifiers: Array.isArray(event.modifiers) ? event.modifiers : [], + }, + }; + } + if (event.kind === 'scroll') { + return { + type: 'scroll', + pageId, + timestamp, + selector: event.selector, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + deltaX: event.deltaX || 0, + deltaY: event.deltaY || 0, + }, + }; + } + if (event.kind === 'drag_element') { + return { + type: 'drag_element', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + targetSelector: event.targetSelector, + targetX: event.targetX, + targetY: event.targetY, + targetNth: event.targetNth, + }, + }; + } + if (event.kind === 'pointer_action') { + return { + type: 'pointer_action', + pageId, + timestamp, + args: { + actions: Array.isArray(event.actions) ? event.actions : [], + }, + }; + } + return null; +} + function parseEventCondition(value) { if (!value || typeof value !== 'object') { throw new Error('event is required'); @@ -3858,6 +4064,19 @@ async function handleClosePage(toolArgs) { const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { allowImplicitFallback: false, }); + if (activeRecordingSession && activeRecordingSession.pageId === page.id) { + try { + await finalizeRecordingCapture(activeRecordingSession); + } catch { + // Keep the recording session and still report the page-close warning below. + } + activeRecordingSession.status = 'stopped'; + activeRecordingSession.stoppedAt = new Date().toISOString(); + activeRecordingSession.warnings.push('recording stopped because target page was closed'); + latestRecordingSession = activeRecordingSession; + activeRecordingSession = null; + } + await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); const interceptSession = interceptSessionsByPageId.get(page.id); if (interceptSession) { @@ -4142,6 +4361,127 @@ async function handleDragFiles(toolArgs) { }); } +function buildRecordingInstallExpression(recordingPayload) { + return `(() => { + const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))}); + globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = { + installed: true, + events: Array.isArray(recordingPayload.events) ? recordingPayload.events : [], + warnings: Array.isArray(recordingPayload.warnings) ? recordingPayload.warnings : [], + }; + return { installed: true }; + })()`; +} + +async function installRecorderAndCapture(page) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: buildRecordingInstallExpression({ events: [], warnings: [] }), + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'recording injection failed'); + } + + const result = response?.result || null; + if (!result) { + throw new Error('recording injection failed'); + } + if (result.subtype === 'error') { + throw new Error(result.description || 'recording injection failed'); + } + + return result.value || { installed: true }; +} + +async function finalizeRecordingCapture(session) { + const page = { + id: session.pageId, + webSocketDebuggerUrl: session.pageWebSocketDebuggerUrl, + }; + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: `(() => globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] })()`, + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'recording injection failed'); + } + + const value = response?.result?.value || { events: [], warnings: [] }; + const rawEvents = Array.isArray(value.events) ? value.events : []; + const warnings = Array.isArray(value.warnings) ? value.warnings : []; + + session.rawEvents = rawEvents; + session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean); + session.warnings = warnings.map((warning) => String(warning.message || warning)); +} + +async function handleStartRecording(toolArgs) { + if (activeRecordingSession) { + throw new Error('recording 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.`); + } + + activeRecordingSession = { + recordingId: createRecordingId(), + pageId: page.id, + pageIndex, + startedAt: new Date().toISOString(), + stoppedAt: null, + status: 'recording', + steps: [], + warnings: [], + rawEvents: [], + }; + latestRecordingSession = activeRecordingSession; + + await installRecorderAndCapture(page); + activeRecordingSession.pageWebSocketDebuggerUrl = page.webSocketDebuggerUrl; + activeRecordingSession.captureInstalled = true; + + return formatRecordingSummary(activeRecordingSession); +} + +async function handleStopRecording() { + if (!activeRecordingSession) { + throw new Error('no active recording'); + } + await finalizeRecordingCapture(activeRecordingSession); + activeRecordingSession.status = 'stopped'; + activeRecordingSession.stoppedAt = new Date().toISOString(); + const stopped = activeRecordingSession; + activeRecordingSession = null; + latestRecordingSession = stopped; + return formatRecordingSummary(stopped); +} + +async function handleGetRecording() { + const session = requireLatestRecording(); + return formatRecordingDetail(session); +} + +async function handleClearRecording() { + if (!latestRecordingSession && !activeRecordingSession) { + throw new Error('no recording available'); + } + activeRecordingSession = null; + latestRecordingSession = null; + return 'status: cleared'; +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -4330,6 +4670,38 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_START_RECORDING) { + const text = await handleStartRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_STOP_RECORDING) { + const text = await handleStopRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_RECORDING) { + const text = await handleGetRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLEAR_RECORDING) { + const text = await handleClearRecording(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 fbae2d58..0921d759 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -132,6 +132,78 @@ type MockDragPlan = { recordedActions?: MockPointerActionRecord[]; }; +type MockRecordedEvent = + | { + kind: 'click'; + selector: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + button?: 'left' | 'middle' | 'right'; + clickCount?: number; + offsetX?: number; + offsetY?: number; + timestamp?: number; + } + | { + kind: 'type'; + selector: string; + text: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'press_key'; + key: string; + modifiers?: string[]; + timestamp?: number; + } + | { + kind: 'scroll'; + selector?: string; + deltaX?: number; + deltaY?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'drag_element'; + selector: string; + targetSelector?: string; + targetX?: number; + targetY?: number; + nth?: number; + targetNth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'pointer_action'; + actions: Array<{ + type: 'move' | 'down' | 'up' | 'pause'; + selector?: string; + x?: number; + y?: number; + button?: 'left' | 'middle' | 'right'; + durationMs?: number; + }>; + timestamp?: number; + }; + +type MockRecordingWarning = { + message: string; +}; + +type MockRecordingPlan = { + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + injectionError?: string; +}; + type MockFrameState = { selector: string; query?: Record; @@ -200,6 +272,7 @@ type MockPageState = { shadowRoots?: MockShadowRootState[]; dropzones?: Record; drag?: MockDragPlan; + recording?: MockRecordingPlan; events?: MockPageEventPlan; intercept?: MockInterceptState; screenshot?: { @@ -433,6 +506,36 @@ function parseParsedJsonArrayArgument(expression: string, key: string): T[] { return []; } +function parseParsedJsonObjectArgument(expression: string, key: string): T | null { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return null; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return null; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + return JSON.parse(decoded) as T; + } + index += 1; + } + + return null; +} + function pickMockMatch( plan: T | T[] | undefined, nth = 0 @@ -500,6 +603,11 @@ function pushPointerAction(page: MockPageState, action: MockPointerActionRecord) page.drag.recordedActions.push(action); } +function getMockRecordingPlan(page: MockPageState): MockRecordingPlan { + page.recording = page.recording || {}; + return page.recording; +} + function shiftPageText(page: MockPageState): string { const queue = page.wait?.pageTextSequence; if (!queue || queue.length === 0) { @@ -961,6 +1069,46 @@ function createMockBrowser(pagesInput: MockPageState[]) { } const expression = String(message.params?.expression || ''); + const recordingPayload = parseParsedJsonObjectArgument<{ + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + }>(expression, 'recordingPayload'); + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ =')) { + const plan = getMockRecordingPlan(page); + if (plan.injectionError) { + reply({ + result: { + type: 'object', + subtype: 'error', + description: plan.injectionError, + }, + }); + return; + } + + if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { + plan.events = recordingPayload.events || []; + plan.warnings = recordingPayload.warnings || []; + } + + reply({ result: { type: 'object', value: { installed: true } } }); + return; + } + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { + const plan = getMockRecordingPlan(page); + reply({ + result: { + type: 'object', + value: { + events: plan.events || [], + warnings: plan.warnings || [], + }, + }, + }); + return; + } if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) { const selector = parseJsonArgument(expression, 'selector') || ''; @@ -1677,6 +1825,10 @@ describe('ccs-browser MCP server', () => { 'browser_drag_files', 'browser_drag_element', 'browser_pointer_action', + 'browser_start_recording', + 'browser_stop_recording', + 'browser_get_recording', + 'browser_clear_recording', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1785,6 +1937,19 @@ describe('ccs-browser MCP server', () => { const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action'); expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' }); + const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording'); + expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording'); + expect(stopRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const getRecordingTool = tools.find((tool) => tool.name === 'browser_get_recording'); + expect(getRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording'); + expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + const queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -5438,6 +5603,197 @@ describe('ccs-browser MCP server', () => { ); }); + it('starts, stops, reads, and clears a recording session', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Recording Page', + currentUrl: 'https://example.com/recording', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + button: 'left', + clickCount: 1, + offsetX: 12, + offsetY: 8, + timestamp: 1710000000000, + }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1001))).toContain('status: recording'); + expect(getResponseText(responses.find((message) => message.id === 1002))).toContain('status: stopped'); + expect(getResponseText(responses.find((message) => message.id === 1003))).toContain('type: click'); + expect(getResponseText(responses.find((message) => message.id === 1004))).toContain('status: cleared'); + }); + + it('rejects invalid recording lifecycle operations', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Recording Page', + currentUrl: 'https://example.com/recording', + }, + ], + [ + { jsonrpc: '2.0', id: 1011, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1012, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1013, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1014, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1015, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1016, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1012))).toContain('recording already active'); + expect(getResponseText(responses.find((message) => message.id === 1014))).toContain('no active recording'); + expect(getResponseText(responses.find((message) => message.id === 1016))).toContain('no recording available'); + }); + + it('routes recording start by pageId and rejects page conflicts', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'First', currentUrl: 'https://example.com/1' }, + { id: 'page-2', title: 'Second', currentUrl: 'https://example.com/2' }, + ], + [ + { + jsonrpc: '2.0', + id: 1017, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 1018, + method: 'tools/call', + params: { name: 'browser_clear_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: { pageIndex: 0, pageId: 'page-1' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1017))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); + }); + + it('normalizes type, press_key, scroll, and warnings in a recording result', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Normalize Page', + currentUrl: 'https://example.com/normalize', + recording: { + events: [ + { kind: 'type', selector: '#email', text: 'walker@example.com', timestamp: 1710000000100 }, + { kind: 'press_key', key: 'Enter', modifiers: ['Shift'], timestamp: 1710000000200 }, + { kind: 'scroll', selector: '#results', deltaX: 0, deltaY: 320, timestamp: 1710000000300 }, + ], + warnings: [{ message: 'cross-origin frame events were skipped' }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1021, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1022, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1023, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1023)); + expect(text).toContain('type: type'); + expect(text).toContain('selector: #email'); + expect(text).toContain('type: press_key'); + expect(text).toContain('type: scroll'); + expect(text).toContain('cross-origin frame events were skipped'); + }); + + it('normalizes drag_element and pointer_action recordings', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Drag Recording Page', + currentUrl: 'https://example.com/drag-recording', + recording: { + events: [ + { + kind: 'drag_element', + selector: '#card-a', + targetSelector: '#lane-b', + timestamp: 1710000000400, + }, + { + kind: 'pointer_action', + actions: [ + { type: 'move', x: 10, y: 20 }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'left' }, + ], + timestamp: 1710000000500, + }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1031, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1032, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1033, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1033)); + expect(text).toContain('type: drag_element'); + expect(text).toContain('selector: #card-a'); + expect(text).toContain('targetSelector: "#lane-b"'); + expect(text).toContain('type: pointer_action'); + expect(text).toContain('actions:'); + }); + + it('stops recording with a warning when the target page becomes unavailable', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Closing Page', + currentUrl: 'https://example.com/closing', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000600 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1034, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1035, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-1' } } }, + { jsonrpc: '2.0', id: 1036, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1036)); + expect(text).toContain('status: stopped'); + expect(text).toContain('recording stopped because target page was closed'); + }); + 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');