diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index f0ce6120..e79e2f52 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -4364,10 +4364,86 @@ async function handleDragFiles(toolArgs) { function buildRecordingInstallExpression(recordingPayload) { return `(() => { const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))}); + const existing = globalThis.__CCS_BROWSER_RECORDING_RECORDER__; + if (existing && existing.installed === true) { + return { installed: true }; + } + + const events = Array.isArray(recordingPayload.events) ? [...recordingPayload.events] : []; + const warnings = Array.isArray(recordingPayload.warnings) ? [...recordingPayload.warnings] : []; + const getSelector = (element) => { + if (!(element instanceof Element)) { + return undefined; + } + if (element.id) { + return '#' + element.id; + } + const attr = typeof element.getAttribute === 'function' ? element.getAttribute('data-testid') : ''; + if (attr) { + return '[data-testid="' + attr + '"]'; + } + return element.tagName ? element.tagName.toLowerCase() : undefined; + }; + const pushEvent = (event) => { + events.push(event); + }; + const onClick = (event) => { + pushEvent({ + kind: 'click', + selector: getSelector(event.target), + button: event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left', + clickCount: event.detail || 1, + offsetX: typeof event.offsetX === 'number' ? event.offsetX : undefined, + offsetY: typeof event.offsetY === 'number' ? event.offsetY : undefined, + timestamp: Date.now(), + }); + }; + const onInput = (event) => { + const target = event.target; + let text = ''; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + text = target.value; + } else if (target && target.isContentEditable === true) { + text = target.textContent || ''; + } else { + return; + } + pushEvent({ kind: 'type', selector: getSelector(target), text, timestamp: Date.now() }); + }; + const onKeyDown = (event) => { + const modifiers = []; + if (event.altKey) modifiers.push('Alt'); + if (event.ctrlKey) modifiers.push('Control'); + if (event.metaKey) modifiers.push('Meta'); + if (event.shiftKey) modifiers.push('Shift'); + pushEvent({ kind: 'press_key', key: event.key, modifiers, timestamp: Date.now() }); + }; + const onScroll = (event) => { + const target = event.target === document ? document.scrollingElement || document.documentElement : event.target; + pushEvent({ + kind: 'scroll', + selector: getSelector(target), + deltaX: 0, + deltaY: 0, + timestamp: Date.now(), + }); + }; + + document.addEventListener('click', onClick, true); + document.addEventListener('input', onInput, true); + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('scroll', onScroll, true); + globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = { installed: true, - events: Array.isArray(recordingPayload.events) ? recordingPayload.events : [], - warnings: Array.isArray(recordingPayload.warnings) ? recordingPayload.warnings : [], + events, + warnings, + teardown: () => { + document.removeEventListener('click', onClick, true); + document.removeEventListener('input', onInput, true); + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('scroll', onScroll, true); + }, }; return { installed: true }; })()`; @@ -4435,7 +4511,7 @@ async function handleStartRecording(toolArgs) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } - activeRecordingSession = { + const session = { recordingId: createRecordingId(), pageId: page.id, pageIndex, @@ -4445,14 +4521,16 @@ async function handleStartRecording(toolArgs) { steps: [], warnings: [], rawEvents: [], + pageWebSocketDebuggerUrl: page.webSocketDebuggerUrl, + captureInstalled: false, }; - latestRecordingSession = activeRecordingSession; await installRecorderAndCapture(page); - activeRecordingSession.pageWebSocketDebuggerUrl = page.webSocketDebuggerUrl; - activeRecordingSession.captureInstalled = true; + session.captureInstalled = true; + activeRecordingSession = session; + latestRecordingSession = session; - return formatRecordingSummary(activeRecordingSession); + return formatRecordingSummary(session); } async function handleStopRecording() { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 0921d759..6c3c0bcf 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -5695,6 +5695,28 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); }); + it('rolls back recording state when recorder injection fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Recording Page', + currentUrl: 'https://example.com/broken-recording', + recording: { + injectionError: 'recording injection failed', + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed'); + expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available'); + }); + it('normalizes type, press_key, scroll, and warnings in a recording result', async () => { const responses = await runMcpRequests( [