import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import * as http from 'node:http'; import { WebSocketServer } from 'ws'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; type JsonRpcMessage = Record; type MockPageState = { id: string; title: string; currentUrl: string; readyStateSequence?: string[]; visibleText?: string; domSnapshot?: string; navigate?: Record; click?: Record< string, { error?: string; disabled?: boolean; detached?: boolean; hidden?: boolean; requireMouseSequence?: boolean; requireNativeClick?: boolean; forbidSyntheticClickEvent?: boolean; cancelMouseDown?: boolean; cancelMouseUp?: boolean; detachAfterMouseDown?: boolean; mouseSequenceError?: string; } >; screenshot?: { data?: string; lastCaptureBeyondViewport?: boolean; }; type?: Record< string, { kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; inputType?: string; value?: string; expectedValueWhenClearFirst?: string; expectedValueWhenAppend?: string; requireFocus?: boolean; focused?: boolean; } >; }; const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); type RunMcpRequestsOptions = { serverPath?: string; childEnv?: NodeJS.ProcessEnv; }; function encodeMessage(message: unknown): string { return `${JSON.stringify(message)}\n`; } function collectResponses( child: ReturnType, expectedCount: number ): Promise>> { return new Promise((resolve, reject) => { let buffer = Buffer.alloc(0); const responses: Array> = []; const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); function tryParse(): void { while (true) { const newlineIndex = buffer.indexOf('\n'); if (newlineIndex === -1) { return; } const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); buffer = buffer.subarray(newlineIndex + 1); if (!body) { continue; } responses.push(JSON.parse(body) as Record); if (responses.length >= expectedCount) { clearTimeout(timer); resolve(responses); return; } } } if (!child.stdout) { clearTimeout(timer); reject(new Error('MCP child stdout is unavailable')); return; } child.stdout.on('data', (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]); try { tryParse(); } catch (error) { clearTimeout(timer); reject(error); } }); child.on('error', (error) => { clearTimeout(timer); reject(error); }); }); } function getResponseText(message: Record | undefined): string { const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; return result.content?.[0]?.text || ''; } function parseJsonArgument(expression: string, key: string): string | undefined { const marker = `const ${key} = JSON.parse(`; const start = expression.indexOf(marker); if (start === -1) { return undefined; } const quoteStart = start + marker.length; const quote = expression[quoteStart]; if (quote !== '"' && quote !== "'") { return undefined; } 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; if (!decoded.startsWith('"') && !decoded.startsWith("'")) { return undefined; } return JSON.parse(decoded) as string; } index += 1; } return undefined; } function createMockBrowser(pagesInput: MockPageState[]) { let tempDir = ''; let httpServer: http.Server | null = null; let wsServer: WebSocketServer | null = null; const pageStates = new Map(); for (const [index, page] of pagesInput.entries()) { pageStates.set(`/devtools/page/${index + 1}`, { visibleText: 'Hello from visible text', domSnapshot: 'Hello from DOM snapshot', ...page, }); } async function start(options: RunMcpRequestsOptions = {}) { const entryServerPath = options.serverPath || bundledServerPath; const childEnv = options.childEnv || {}; tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); const port = await new Promise((resolve, reject) => { httpServer = http.createServer((req, res) => { if (req.url === '/json/list') { const address = httpServer?.address(); const serverPort = address && typeof address !== 'string' ? address.port : 0; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify( Array.from(pageStates.entries()).map(([wsPath, page]) => ({ id: page.id, type: 'page', title: page.title, url: page.currentUrl, webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, })) ) ); return; } res.writeHead(404); res.end('not found'); }); httpServer.once('error', reject); httpServer.listen(0, '127.0.0.1', () => { const address = httpServer?.address(); if (!address || typeof address === 'string') { reject(new Error('Failed to resolve mock browser server port')); return; } resolve(address.port); }); }); wsServer = new WebSocketServer({ server: httpServer as http.Server }); wsServer.on('connection', (socket, request) => { const page = pageStates.get(request.url || ''); if (!page) { socket.close(); return; } socket.on('message', (raw) => { const message = JSON.parse(raw.toString()) as { id: number; method: string; params?: Record; }; function reply(result: unknown): void { socket.send(JSON.stringify({ id: message.id, result })); } function replyError(errorText: string): void { socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); } if (message.method === 'Page.navigate') { const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; const navigatePlan = page.navigate?.[targetUrl]; if (navigatePlan?.errorText) { reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); return; } if (navigatePlan) { page.currentUrl = navigatePlan.finalUrl; page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; } reply({ frameId: 'frame-1' }); return; } if (message.method === 'Page.captureScreenshot') { if (!page.screenshot) { reply({ data: '' }); return; } page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; reply({ data: page.screenshot.data || '' }); return; } if (message.method !== 'Runtime.evaluate') { return; } const expression = String(message.params?.expression || ''); if (expression.includes('document.title') && expression.includes('location.href')) { reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); return; } if (expression.includes('document.body ? document.body.innerText')) { reply({ result: { type: 'string', value: page.visibleText || '' } }); return; } if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { reply({ result: { type: 'string', value: page.domSnapshot || '' } }); return; } if (expression.includes('document.readyState') && expression.includes('location.href')) { const readyState = page.readyStateSequence?.shift() || 'complete'; reply({ result: { type: 'string', value: JSON.stringify({ href: page.currentUrl, readyState }), }, }); return; } if (expression.includes('scrollIntoView') && expression.includes('.click()')) { const selector = parseJsonArgument(expression, 'selector') || ''; const clickPlan = page.click?.[selector]; const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); const readsDispatchResult = expression.includes('const dispatchResult = {'); const gatesNativeClickOnDispatchResult = expression.includes('if (!dispatchResult.shouldActivate)'); const checksIsConnectedBeforeNativeClick = expression.includes('if (!element.isConnected)'); const catchIndex = expression.indexOf('catch (mouseError) {'); const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( (match) => match.index ?? -1 ); const attemptedFallbackClick = nativeClickIndexes.some( (index) => catchIndex !== -1 && catchBlockEnd !== -1 && index > catchIndex && index < catchBlockEnd ); const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( (index) => catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd ); if (!clickPlan) { replyError(`element not found for selector: ${selector}`); return; } if (clickPlan.detached && expression.includes('element.isConnected')) { replyError(`element is detached for selector: ${selector}`); return; } if (clickPlan.disabled) { replyError(`element is disabled for selector: ${selector}`); return; } if (clickPlan.hidden && expression.includes('getBoundingClientRect')) { replyError(`element is hidden or not interactable for selector: ${selector}`); return; } if (clickPlan.requireMouseSequence && !attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; } if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { replyError(`synthetic click event forbidden for selector: ${selector}`); return; } if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) { replyError(`dispatch result must be checked for selector: ${selector}`); return; } if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { replyError(`native click must be gated for selector: ${selector}`); return; } if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { replyError(`connected state must be rechecked for selector: ${selector}`); return; } if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { replyError(`native click required for selector: ${selector}`); return; } if (clickPlan.mouseSequenceError) { if (!attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; } if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { reply({ result: { type: 'string', value: 'ok' } }); return; } replyError(clickPlan.mouseSequenceError); return; } if (clickPlan.error) { replyError(clickPlan.error); return; } reply({ result: { type: 'string', value: 'ok' } }); return; } if (expression.includes('focusTarget') && expression.includes('typedLength')) { const selector = parseJsonArgument(expression, 'selector') || ''; const text = parseJsonArgument(expression, 'text') ?? ''; const clearFirst = expression.includes('const clearFirst = true'); const typePlan = page.type?.[selector]; if (!typePlan) { replyError(`element not found for selector: ${selector}`); return; } if (typePlan.kind === 'unsupported') { replyError(`element is not text-editable for selector: ${selector}`); return; } if (typePlan.kind === 'noneditable') { replyError(`element is not text-editable for selector: ${selector}`); return; } if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { replyError(`focus was not requested for selector: ${selector}`); return; } const currentValue = typePlan.value || ''; const expectedValue = clearFirst ? (typePlan.expectedValueWhenClearFirst ?? text) : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); typePlan.focused = true; typePlan.value = expectedValue; reply({ result: { type: 'string', value: JSON.stringify({ value: expectedValue, typedLength: expectedValue.length, }), }, }); return; } }); }); const child = spawn('node', [entryServerPath], { cwd: tempDir, env: { ...process.env, ...childEnv, CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, }, stdio: ['pipe', 'pipe', 'pipe'], }); return child; } async function stop() { await new Promise((resolve) => { wsServer?.close(() => resolve()); if (!wsServer) resolve(); }); wsServer = null; await new Promise((resolve) => { httpServer?.close(() => resolve()); if (!httpServer) resolve(); }); httpServer = null; if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } } return { start, stop }; } async function runMcpRequests( pages: MockPageState[], requests: JsonRpcMessage[], options: RunMcpRequestsOptions = {} ) { const browser = createMockBrowser(pages); const child = await browser.start(options); try { const responsesPromise = collectResponses(child, requests.length + 1); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bun-test', version: '1.0.0' }, }, }) ); for (const request of requests) { child.stdin.write(encodeMessage(request)); } return await responsesPromise; } finally { child.kill(); await browser.stop(); } } describe('ccs-browser MCP server', () => { afterEach(() => { // Cleanup is handled per test via runMcpRequests/createMockBrowser. }); it('lists browser tools including navigate, click, type, and screenshot', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] ); const tools = (responses.find((message) => message.id === 2)?.result as { tools: Array<{ name: string; description?: string; inputSchema?: { properties?: Record }; }>; }).tools; expect(tools.map((tool) => tool.name)).toEqual([ 'browser_get_session_info', 'browser_get_url_and_title', 'browser_get_visible_text', 'browser_get_dom_snapshot', 'browser_navigate', 'browser_click', 'browser_type', 'browser_take_screenshot', ]); const clickTool = tools.find((tool) => tool.name === 'browser_click'); expect(clickTool?.description).toContain('mouse event chain'); expect(clickTool?.description).not.toContain('synthetic element.click()'); for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer', minimum: 0, }); } }); it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); const bootstrapServerPath = join(installDir, 'bootstrap.cjs'); try { cpSync(bundledServerPath, installedServerPath); writeFileSync( bootstrapServerPath, 'delete globalThis.WebSocket;\nrequire("./ccs-browser-server.cjs");\n', 'utf8' ); const responses = await runMcpRequests( [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_get_url_and_title', arguments: {} }, }, ], { serverPath: bootstrapServerPath, childEnv: { NODE_PATH: join(process.cwd(), 'node_modules'), }, } ); const response = responses.find((message) => message.id === 2); expect((response?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(response)).toContain('title: Installed Copy'); expect(getResponseText(response)).toContain('url: https://example.com/'); } finally { rmSync(installDir, { recursive: true, force: true }); } }); it('navigates successfully after readiness polling', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', navigate: { 'https://example.com/next': { finalUrl: 'https://example.com/next', readyStates: ['loading', 'interactive'], }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: 'https://example.com/next' }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 2)); expect(text).toContain('pageIndex: 0'); expect(text).toContain('url: https://example.com/next'); expect(text).toContain('status: navigated'); }); it( 'returns a handled error when navigation readiness times out', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', navigate: { 'https://example.com/slow': { finalUrl: 'https://example.com/', readyStates: new Array(60).fill('loading'), }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: 'https://example.com/slow' }, }, }, ] ); const response = responses.find((message) => message.id === 2); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); }, 8000 ); it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_navigate', arguments: {} }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, }, { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, }, { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required'); expect(getResponseText(responses.find((message) => message.id === 3))).toContain( 'Browser MCP failed: url must be an absolute http or https URL' ); expect(getResponseText(responses.find((message) => message.id === 4))).toContain( 'Browser MCP failed: pageIndex must be a non-negative integer' ); expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range'); }); it('surfaces Page.navigate CDP failures as handled errors', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/start', navigate: { 'https://example.com/fail': { finalUrl: 'https://example.com/start', errorText: 'net::ERR_ABORTED', }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: 'https://example.com/fail' }, }, }, ] ); const response = responses.find((message) => message.id === 2); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain( 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' ); }); it('treats redirects as successful navigation', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/start', navigate: { 'https://example.com/redirect': { finalUrl: 'https://example.com/final', readyStates: ['loading', 'interactive'], }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: 'https://example.com/redirect' }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 2)); expect(text).toContain('status: navigated'); expect(text).toContain('url: https://example.com/final'); }); it('clicks matching elements and reports selector failures', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#submit': {}, '#disabled': { disabled: true }, '#throws': { error: 'click exploded' }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#submit' } }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#missing' } }, }, { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#disabled' } }, }, { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#throws' } }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); const selectorMiss = responses.find((message) => message.id === 3); expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); const disabledError = responses.find((message) => message.id === 4); expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); const pageSideError = responses.find((message) => message.id === 5); expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(pageSideError)).toContain('click exploded'); }); it('reports detached and hidden click targets as handled errors', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#hidden': { hidden: true }, '#detached': { detached: true }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#hidden' } }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#detached' } }, }, ] ); const hiddenError = responses.find((message) => message.id === 2); expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(hiddenError)).toContain('element is hidden or not interactable for selector: #hidden'); const detachedError = responses.find((message) => message.id === 3); expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); }); it('uses a mouse sequence when the target requires it', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#menu-trigger': { requireMouseSequence: true }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #menu-trigger'); }); it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#click-event': { requireMouseSequence: true, forbidSyntheticClickEvent: true, requireNativeClick: true, }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#click-event' } }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #click-event'); }); it('preserves native click activation after the mouse sequence', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#native-click': { requireMouseSequence: true, requireNativeClick: true, }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#native-click' } }, }, ] ); const clickResponse = responses.find((message) => message.id === 2); expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(clickResponse)).toContain('status: clicked'); expect(getResponseText(clickResponse)).toContain('selector: #native-click'); }); it('does not force activation when mousedown cancels the interaction', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#cancel-mousedown': { requireMouseSequence: true, cancelMouseDown: true, }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, }, ] ); const clickResponse = responses.find((message) => message.id === 2); expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(clickResponse)).toContain('status: clicked'); expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); }); it('rechecks connectivity before native activation after the mouse sequence', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#detached-during-click': { requireMouseSequence: true, requireNativeClick: true, detachAfterMouseDown: true, }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, }, ] ); const clickResponse = responses.find((message) => message.id === 2); expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(clickResponse)).toContain('status: clicked'); expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); }); it('falls back to click when mouse sequence dispatch fails', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', click: { '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#fallback' } }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback'); }); it('captures screenshots and reports empty payload failures', async () => { const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', screenshot: screenshotPlan, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_take_screenshot', arguments: { fullPage: true }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 2)); expect(text).toContain('pageIndex: 0'); expect(text).toContain('format: png'); expect(text).toContain('fullPage: true'); expect(text).toContain('data: c2NyZWVuc2hvdA=='); expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); const errorResponses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', screenshot: { data: '' }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_take_screenshot', arguments: {}, }, }, ] ); const errorResponse = errorResponses.find((message) => message.id === 2); expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed'); }); it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/', type: { 'input[name="email"]': { kind: 'input', inputType: 'email', value: 'old@example.com', expectedValueWhenAppend: 'old@example.comhi@example.com', requireFocus: true, }, '#notes': { kind: 'textarea', value: 'old note', expectedValueWhenClearFirst: '', requireFocus: true, }, '#editor': { kind: 'contenteditable', value: 'old content', expectedValueWhenAppend: 'old contentrich text', requireFocus: true, }, '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, '#plain': { kind: 'noneditable', value: 'plain' }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_type', arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_type', arguments: { selector: '#notes', text: '', clearFirst: true }, }, }, { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'browser_type', arguments: { selector: '#editor', text: 'rich text' }, }, }, { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'browser_type', arguments: { selector: '#color', text: 'ignored' }, }, }, { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'browser_type', arguments: { selector: '#plain', text: 'ignored' }, }, }, ] ); const typed = getResponseText(responses.find((message) => message.id === 2)); expect(typed).toContain('status: typed'); expect(typed).toContain('selector: input[name="email"]'); expect(typed).toContain('typedLength: 29'); const clearFirst = getResponseText(responses.find((message) => message.id === 3)); expect(clearFirst).toContain('status: typed'); expect(clearFirst).toContain('selector: #notes'); expect(clearFirst).toContain('typedLength: 0'); const contenteditable = getResponseText(responses.find((message) => message.id === 4)); expect(contenteditable).toContain('status: typed'); expect(contenteditable).toContain('selector: #editor'); expect(contenteditable).toContain('typedLength: 20'); const unsupported = responses.find((message) => message.id === 5); expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color'); const nonEditable = responses.find((message) => message.id === 6); expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain'); }); });