import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; import { cpSync, existsSync, 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 MockRect = { x: number; y: number; width: number; height: number; top: number; right: number; bottom: number; left: number; }; type MockQueryState = { exists?: boolean; connected?: boolean; innerText?: string; textContent?: string; rect?: MockRect; display?: string; visibility?: string; opacity?: string; href?: string; onclick?: string; error?: string; }; type MockQueryPlan = MockQueryState | MockQueryState[]; type MockClickState = { error?: string; disabled?: boolean; detached?: boolean; hidden?: boolean; requireMouseSequence?: boolean; requireNativeClick?: boolean; forbidSyntheticClickEvent?: boolean; cancelMouseDown?: boolean; cancelMouseUp?: boolean; detachAfterMouseDown?: boolean; mouseSequenceError?: string; label?: string; expectedOffset?: { x: number; y: number }; expectedButton?: 'left' | 'middle' | 'right'; expectedClickCount?: number; requireDoubleClickEvent?: boolean; }; type MockClickPlan = MockClickState | MockClickState[]; type MockHoverState = { error?: string; detached?: boolean; hidden?: boolean; zeroSized?: boolean; requireCdpMouseMove?: boolean; lastMouseMove?: { x: number; y: number; }; }; type MockWaitPlan = { selectorSnapshots?: Record; pageTextSequence?: string[]; }; type MockDownloadProgressState = { receivedBytes: number; totalBytes: number; state: 'inProgress' | 'completed' | 'canceled'; filePath?: string; }; type MockDownloadState = { guid?: string; url: string; suggestedFilename: string; progress?: MockDownloadProgressState[]; }; type MockBrowserState = { setDownloadBehaviorCalls?: Array<{ behavior: string; downloadPath?: string; eventsEnabled?: boolean; }>; canceledDownloadGuids?: string[]; }; type MockPageEventPlan = { dialogs?: Array<{ type: string; message: string }>; navigations?: Array<{ url: string; parentId?: string }>; requests?: Array<{ url: string; method: string }>; downloads?: MockDownloadState[]; }; type MockFileInputState = { kind: 'file' | 'nonfile'; multiple?: boolean; assignedFiles?: string[]; }; type MockFileInputPlan = MockFileInputState | MockFileInputState[]; type MockDropzoneState = { accepted?: boolean; requireFiles?: boolean; receivedEventTypes?: string[]; receivedFiles?: Array<{ name: string; size: number; type: string }>; error?: string; }; type MockDropzonePlan = MockDropzoneState | MockDropzoneState[]; type MockPointerActionRecord = { type: string; x?: number; y?: number; button?: string; }; 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; visibleText?: string; fileInputs?: Record; dropzones?: Record; }; type MockShadowRootState = { hostSelector: string; query?: Record; fileInputs?: Record; dropzones?: Record; }; type MockEvalPlan = Record< string, { result?: unknown; error?: string; nonSerializable?: boolean; } >; type MockInterceptRuleMatch = { url: string; method: string; resourceType?: string; requestId?: string; requestHeaders?: Record; }; type MockFulfilledRequest = { requestId: string; responseCode?: number; responseHeaders?: Array<{ name: string; value: string }>; body?: string; }; type MockInterceptState = { pausedRequests?: MockInterceptRuleMatch[]; continuedRequestIds?: string[]; failedRequests?: Array<{ requestId: string; errorReason?: string }>; fulfilledRequests?: MockFulfilledRequest[]; fetchEnabledPatterns?: unknown[]; enableError?: string; pauseDispatchDelayMs?: number; }; type MockPageState = { id: string; title: string; currentUrl: string; fileInputs?: Record; browser?: MockBrowserState; readyStateSequence?: string[]; visibleText?: string; domSnapshot?: string; navigate?: Record; click?: Record; hover?: Record; query?: Record; wait?: MockWaitPlan; eval?: MockEvalPlan; frames?: MockFrameState[]; shadowRoots?: MockShadowRootState[]; dropzones?: Record; drag?: MockDragPlan; recording?: MockRecordingPlan; events?: MockPageEventPlan; intercept?: MockInterceptState; screenshot?: { expectedClip?: { x: number; y: number; width: number; height: number; scale: number; }; requireScrolledMeasurement?: boolean; scrolledSelectors?: string[]; data?: string; lastCaptureBeyondViewport?: boolean; lastClip?: { x: number; y: number; width: number; height: number; scale: number; }; }; type?: Record< string, { kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; inputType?: string; value?: string; expectedValueWhenClearFirst?: string; expectedValueWhenAppend?: string; requireFocus?: boolean; focused?: boolean; } >; keyboard?: { expectedKey?: string; expectedModifiers?: string[]; expectedRepeat?: number; _seenKeyDownCount?: number; }; scroll?: Record< string, { expectedBehavior?: 'into-view' | 'by-offset'; expectedDeltaX?: number; expectedDeltaY?: number; } >; }; const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); type RunMcpRequestsOptions = { serverPath?: string; childEnv?: NodeJS.ProcessEnv; responseTimeoutMs?: number; }; function encodeMessage(message: unknown): string { return `${JSON.stringify(message)}\n`; } function collectResponses( child: ReturnType, expectedCount: number, timeoutMs = 7000 ): Promise>> { return new Promise((resolve, reject) => { let buffer = Buffer.alloc(0); let stderrBuffer = ''; let settled = false; const responses: Array> = []; const fail = (error: Error) => { if (settled) { return; } settled = true; clearTimeout(timer); reject(error); }; const finish = () => { if (settled) { return; } settled = true; clearTimeout(timer); resolve(responses); }; const timer = setTimeout(() => { const details = stderrBuffer.trim(); fail( new Error( details ? `Timed out waiting for MCP responses\n${details}` : 'Timed out waiting for MCP responses' ) ); }, timeoutMs); 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) { finish(); return; } } } if (!child.stdout) { fail(new Error('MCP child stdout is unavailable')); return; } child.stdout.on('data', (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]); try { tryParse(); } catch (error) { fail(error instanceof Error ? error : new Error(String(error))); } }); child.stderr?.on('data', (chunk: Buffer | string) => { stderrBuffer += chunk.toString(); }); child.on('error', (error) => { fail(error); }); child.on('exit', (code, signal) => { if (settled || responses.length >= expectedCount) { return; } const details = stderrBuffer.trim(); const suffix = details ? `\n${details}` : ''; fail( new Error( `MCP child exited before all responses arrived (code=${code}, signal=${signal})${suffix}` ) ); }); }); } function getResponseText(message: Record | undefined): string { const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; return result.content?.[0]?.text || ''; } function createReplayStep(step: Record): Record { return step; } function createOrchestrationBlock(block: Record): Record { return block; } 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 parseNumberArgument(expression: string, key: string): number | undefined { const match = expression.match(new RegExp(`const ${key} = ([0-9]+|undefined);`)); if (!match?.[1] || match[1] === 'undefined') { return undefined; } return Number.parseInt(match[1], 10); } function parseParsedJsonArrayArgument(expression: string, key: string): T[] { const marker = `const ${key} = JSON.parse(`; const start = expression.indexOf(marker); if (start === -1) { return []; } const quoteStart = start + marker.length; const quote = expression[quoteStart]; if (quote !== '"' && quote !== "'") { return []; } 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 []; } 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 ): { count: number; target: T | undefined; } { if (Array.isArray(plan)) { return { count: plan.length, target: plan[nth] }; } return { count: plan ? 1 : 0, target: nth === 0 ? plan : undefined }; } function shiftSelectorSnapshot(page: MockPageState, selector: string): MockQueryPlan | undefined { const queue = page.wait?.selectorSnapshots?.[selector]; if (!queue || queue.length === 0) { return page.query?.[selector]; } if (queue.length === 1) { return queue[0]; } return queue.shift(); } function getMockFrame(page: MockPageState, frameSelector: string): MockFrameState | undefined { return page.frames?.find((frame) => frame.selector === frameSelector); } function getMockShadowRoot(page: MockPageState): MockShadowRootState | undefined { return page.shadowRoots?.[0]; } function getMockDropzoneState( page: Pick, selector: string, options: { nth?: number; frameSelector?: string; pierceShadow?: boolean } = {} ): MockDropzoneState | null { const nth = options.nth ?? 0; if (options.frameSelector) { const frame = page.frames?.find((entry) => entry.selector === options.frameSelector); const plan = frame?.dropzones?.[selector]; if (!plan) { return null; } return Array.isArray(plan) ? (plan[nth] ?? null) : plan; } if (options.pierceShadow) { for (const shadowRoot of page.shadowRoots || []) { const plan = shadowRoot.dropzones?.[selector]; if (plan) { return Array.isArray(plan) ? (plan[nth] ?? null) : plan; } } } const plan = page.dropzones?.[selector]; if (!plan) { return null; } return Array.isArray(plan) ? (plan[nth] ?? null) : plan; } function pushPointerAction(page: MockPageState, action: MockPointerActionRecord): void { page.drag = page.drag || {}; page.drag.recordedActions = page.drag.recordedActions || []; 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) { return page.visibleText || ''; } if (queue.length === 1) { return queue[0] || ''; } return queue.shift() || ''; } function resolveNodeModulesPath(): string { const candidates = [ join(process.cwd(), 'node_modules'), join(process.cwd(), '..', 'node_modules'), join(process.cwd(), '..', '..', 'node_modules'), ]; return ( candidates.find((candidate) => existsSync(join(candidate, 'ws'))) || candidates.find((candidate) => existsSync(candidate)) || candidates[0] ); } function createMockBrowser(pagesInput: MockPageState[]) { let tempDir = ''; let httpServer: http.Server | null = null; let wsServer: WebSocketServer | null = null; let browserSocketPath = ''; const pageStates = new Map(); let nextPageCounter = pagesInput.length + 1; for (const [index, page] of pagesInput.entries()) { if (page.visibleText === undefined) { page.visibleText = 'Hello from visible text'; } if (page.domSnapshot === undefined) { page.domSnapshot = 'Hello from DOM snapshot'; } pageStates.set(`/devtools/page/${index + 1}`, 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) => { const address = httpServer?.address(); const serverPort = address && typeof address !== 'string' ? address.port : 0; if (req.url === '/json/list') { const pageEntries = Array.from(pageStates.entries()); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify([ ...pageEntries.map(([wsPath, page]) => ({ id: page.id, type: 'page', title: page.title, url: page.currentUrl, webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, })), { id: 'browser-target', type: 'browser', title: 'Browser', url: '', webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${browserSocketPath || '/devtools/browser'}`, }, ]) ); return; } if (req.url?.startsWith('/json/new')) { const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`); const requestedUrl = parsed.searchParams.get('url') || 'about:blank'; const wsPath = `/devtools/page/${nextPageCounter}`; const newPage: MockPageState = { id: `page-${nextPageCounter}`, title: requestedUrl === 'about:blank' ? 'about:blank' : requestedUrl, currentUrl: requestedUrl, visibleText: 'New page visible text', domSnapshot: 'New page', }; pageStates.set(wsPath, newPage); nextPageCounter += 1; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ id: newPage.id, type: 'page', title: newPage.title, url: newPage.currentUrl, webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, }) ); return; } if (req.url?.startsWith('/json/close/')) { const targetId = decodeURIComponent(req.url.slice('/json/close/'.length)); const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId); if (!entry) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'page not found' })); return; } pageStates.delete(entry[0]); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ id: targetId })); 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); }); }); browserSocketPath = '/devtools/browser'; wsServer = new WebSocketServer({ server: httpServer as http.Server }); wsServer.on('connection', (socket, request) => { if ((request.url || '') === browserSocketPath) { const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); 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 })); } if (message.method === 'Browser.setDownloadBehavior') { browserState.setDownloadBehaviorCalls = browserState.setDownloadBehaviorCalls || []; browserState.setDownloadBehaviorCalls.push({ behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', downloadPath: typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, eventsEnabled: typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, }); reply({}); return; } if (message.method === 'Browser.cancelDownload') { browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || []; browserState.canceledDownloadGuids.push(String(message.params?.guid || '')); reply({}); } }); for (const page of pagesInput) { for (const [index, download] of (page.events?.downloads || []).entries()) { const guid = download.guid || `${page.id}-download-${index + 1}`; setTimeout(() => { socket.send( JSON.stringify({ method: 'Browser.downloadWillBegin', params: { frameId: `frame-${page.id}`, guid, url: download.url, suggestedFilename: download.suggestedFilename, }, }) ); }, 10 + index * 40); for (const [progressIndex, progress] of (download.progress || []).entries()) { setTimeout(() => { socket.send( JSON.stringify({ method: 'Browser.downloadProgress', params: { guid, totalBytes: progress.totalBytes, receivedBytes: progress.receivedBytes, state: progress.state, filePath: progress.filePath, }, }) ); }, 20 + index * 40 + progressIndex * 20); } } } return; } const page = pageStates.get(request.url || ''); if (!page) { socket.close(); return; } const remoteObjects = new Map(); 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; const clip = message.params?.clip && typeof message.params.clip === 'object' ? (message.params.clip as Record) : null; page.screenshot.lastClip = clip ? { x: Number(clip.x), y: Number(clip.y), width: Number(clip.width), height: Number(clip.height), scale: Number(clip.scale), } : undefined; if (page.screenshot.expectedClip) { expect(page.screenshot.lastClip).toEqual(page.screenshot.expectedClip); } reply({ data: page.screenshot.data || '' }); return; } if (message.method === 'Input.dispatchMouseEvent') { const type = typeof message.params?.type === 'string' ? message.params.type : ''; const x = Number(message.params?.x); const y = Number(message.params?.y); const button = typeof message.params?.button === 'string' ? message.params.button : undefined; for (const hoverPlan of Object.values(page.hover || {})) { if (type === 'mouseMoved') { hoverPlan.lastMouseMove = { x, y }; } } pushPointerAction(page, { type, x, y, button }); reply({}); return; } if (message.method === 'Input.dispatchKeyEvent') { const type = typeof message.params?.type === 'string' ? message.params.type : ''; const key = typeof message.params?.key === 'string' ? message.params.key : ''; const modifiersMask = Number(message.params?.modifiers || 0); const modifiers = [ ...(modifiersMask & 1 ? ['Alt'] : []), ...(modifiersMask & 2 ? ['Control'] : []), ...(modifiersMask & 4 ? ['Meta'] : []), ...(modifiersMask & 8 ? ['Shift'] : []), ]; const keyboardPlan = page.keyboard; if (type === 'keyDown') { if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { replyError(`unexpected key: ${key}`); return; } if ( keyboardPlan?.expectedModifiers && JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) ) { replyError(`unexpected modifiers for key: ${key}`); return; } if (keyboardPlan) { keyboardPlan._seenKeyDownCount = (keyboardPlan._seenKeyDownCount || 0) + 1; } } if ( type === 'keyUp' && typeof keyboardPlan?.expectedRepeat === 'number' && (keyboardPlan._seenKeyDownCount || 0) !== keyboardPlan.expectedRepeat ) { replyError(`unexpected repeat for key: ${key}`); return; } reply({}); return; } if (message.method === 'Page.enable' || message.method === 'Network.enable') { reply({}); if (message.method === 'Page.enable') { const dialog = page.events?.dialogs?.[0]; const navigations = page.events?.navigations || []; if (dialog) { setTimeout(() => { socket.send( JSON.stringify({ method: 'Page.javascriptDialogOpening', params: { message: dialog.message, type: dialog.type }, }) ); }, 10); } navigations.forEach((navigation, index) => { setTimeout( () => { socket.send( JSON.stringify({ method: 'Page.frameNavigated', params: { frame: { url: navigation.url, parentId: navigation.parentId } }, }) ); }, 10 + index * 10 ); }); } if (message.method === 'Network.enable') { const requestPlan = page.events?.requests?.[0]; if (requestPlan) { setTimeout(() => { socket.send( JSON.stringify({ method: 'Network.requestWillBeSent', params: { request: { url: requestPlan.url, method: requestPlan.method } }, }) ); }, 10); } } return; } if (message.method === 'Fetch.enable') { page.intercept = page.intercept || {}; page.intercept.fetchEnabledPatterns = Array.isArray(message.params?.patterns) ? (message.params?.patterns as unknown[]) : []; if (page.intercept.enableError) { socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })); return; } reply({}); const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10; for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) { setTimeout(() => { socket.send( JSON.stringify({ method: 'Fetch.requestPaused', params: { requestId: paused.requestId || `fetch-${index + 1}`, resourceType: paused.resourceType || 'XHR', request: { url: paused.url, method: paused.method, headers: paused.requestHeaders || {}, }, }, }) ); }, pauseDispatchDelayMs + index * 10); } return; } if (message.method === 'Fetch.continueRequest') { page.intercept = page.intercept || {}; page.intercept.continuedRequestIds = page.intercept.continuedRequestIds || []; page.intercept.continuedRequestIds.push(String(message.params?.requestId || '')); reply({}); return; } if (message.method === 'Fetch.failRequest') { page.intercept = page.intercept || {}; page.intercept.failedRequests = page.intercept.failedRequests || []; page.intercept.failedRequests.push({ requestId: String(message.params?.requestId || ''), errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', }); reply({}); return; } if (message.method === 'Fetch.fulfillRequest') { page.intercept = page.intercept || {}; page.intercept.fulfilledRequests = page.intercept.fulfilledRequests || []; page.intercept.fulfilledRequests.push({ requestId: String(message.params?.requestId || ''), responseCode: typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined, responseHeaders: Array.isArray(message.params?.responseHeaders) ? (message.params.responseHeaders as Array<{ name: string; value: string }>) : [], body: typeof message.params?.body === 'string' ? message.params.body : '', }); reply({}); return; } if (message.method === 'DOM.setFileInputFiles') { const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : ''; const target = remoteObjects.get(objectId); if (!target) { socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } })); return; } target.assignedFiles = Array.isArray(message.params?.files) ? (message.params.files as unknown[]).map((entry) => String(entry)) : []; reply({}); return; } if (message.method !== 'Runtime.evaluate') { return; } 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') || ''; const nth = parseNumberArgument(expression, 'nth') ?? 0; const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; const pierceShadow = expression.includes('const pierceShadow = true'); const filePayloads = parseParsedJsonArrayArgument<{ name: string; size: number; mimeType: string; }>(expression, 'filePayloads'); const eventTypes = Array.from( expression.matchAll(/new DragEvent\('(dragenter|dragover|drop)'/g) ).map((match) => match[1]); const target = getMockDropzoneState(page, selector, { nth, frameSelector, pierceShadow, }); if (!target) { replyError(`element not found for selector: ${selector}`); return; } if (target.error) { replyError(target.error); return; } if (eventTypes.length === 0) { replyError(`drag event sequence not found for selector: ${selector}`); return; } if (target.requireFiles && filePayloads.length === 0) { reply({ result: { type: 'object', value: { accepted: false } } }); return; } target.receivedEventTypes = eventTypes; target.receivedFiles = filePayloads.map((file) => ({ name: file.name, size: file.size, type: file.mimeType, })); reply({ result: { type: 'object', value: { accepted: target.accepted !== false } } }); return; } if (page.eval?.[expression]) { const evalPlan = page.eval[expression]; if (evalPlan.error) { socket.send( JSON.stringify({ id: message.id, result: { exceptionDetails: { text: evalPlan.error } }, }) ); return; } if (evalPlan.nonSerializable) { socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); return; } socket.send( JSON.stringify({ id: message.id, result: { result: { type: 'object', value: evalPlan.result } }, }) ); return; } if (expression === '0') { reply({ result: { type: 'number', value: 0 } }); return; } if (expression.includes('element is not a file input for selector:')) { const selector = parseJsonArgument(expression, 'selector') || ''; const nth = parseNumberArgument(expression, 'nth') ?? 0; const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; const pierceShadow = expression.includes('const pierceShadow = true'); const fileInputPlan = frameSelector ? getMockFrame(page, frameSelector)?.fileInputs?.[selector] : pierceShadow ? getMockShadowRoot(page)?.fileInputs?.[selector] : page.fileInputs?.[selector]; const { count, target } = pickMockMatch(fileInputPlan, nth); if (!target || count <= nth) { replyError(`element not found for selector: ${selector}`); return; } if (target.kind !== 'file') { replyError(`element is not a file input for selector: ${selector}`); return; } const objectId = `file-input:${page.id}:${selector}:${nth}:${frameSelector || 'root'}:${pierceShadow ? 'shadow' : 'light'}`; remoteObjects.set(objectId, target); socket.send( JSON.stringify({ id: message.id, result: { result: { type: 'object', subtype: 'node', className: 'HTMLInputElement', objectId, }, }, }) ); return; } 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: shiftPageText(page) } }); 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('resolvedOffsetX')) { const selector = parseJsonArgument(expression, 'selector') || ''; const nth = parseNumberArgument(expression, 'nth') ?? 0; const clickPlan = page.click?.[selector]; const { count, target: resolvedClickPlan } = pickMockMatch(clickPlan, nth); const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); const attemptedDoubleClickEvent = expression.includes("new MouseEvent('dblclick'"); 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 ); const offsetX = parseNumberArgument(expression, 'offsetX'); const offsetY = parseNumberArgument(expression, 'offsetY'); const button = parseJsonArgument(expression, 'button') || 'left'; const clickCount = parseNumberArgument(expression, 'clickCount') ?? 1; if (!resolvedClickPlan) { replyError(`element index ${nth} is out of range for selector: ${selector}`); return; } if (count <= nth) { replyError(`element index ${nth} is out of range for selector: ${selector}`); return; } if (resolvedClickPlan.detached && expression.includes('element.isConnected')) { replyError(`element is detached for selector: ${selector}`); return; } if (resolvedClickPlan.disabled) { replyError(`element is disabled for selector: ${selector}`); return; } if (resolvedClickPlan.hidden && expression.includes('getBoundingClientRect')) { replyError(`element is hidden or not interactable for selector: ${selector}`); return; } if (resolvedClickPlan.requireMouseSequence && !attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; } if (resolvedClickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { replyError(`synthetic click event forbidden for selector: ${selector}`); return; } if ( (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !readsDispatchResult ) { replyError(`dispatch result must be checked for selector: ${selector}`); return; } if ( (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult ) { replyError(`native click must be gated for selector: ${selector}`); return; } if (resolvedClickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { replyError(`connected state must be rechecked for selector: ${selector}`); return; } if (resolvedClickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { replyError(`native click required for selector: ${selector}`); return; } if ( resolvedClickPlan.expectedOffset && (offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y) ) { replyError(`unexpected click offset for selector: ${selector}`); return; } if (resolvedClickPlan.expectedButton && button !== resolvedClickPlan.expectedButton) { replyError(`unexpected click button for selector: ${selector}`); return; } if ( typeof resolvedClickPlan.expectedClickCount === 'number' && clickCount !== resolvedClickPlan.expectedClickCount ) { replyError(`unexpected click count for selector: ${selector}`); return; } if ( resolvedClickPlan.requireDoubleClickEvent && clickCount === 2 && !attemptedDoubleClickEvent ) { replyError(`dblclick event required for selector: ${selector}`); return; } if (resolvedClickPlan.mouseSequenceError) { if (!attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; } if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { reply({ result: { type: 'string', value: JSON.stringify({ resolvedOffsetX: offsetX ?? 50, resolvedOffsetY: offsetY ?? 10, button, clickCount, }), }, }); return; } replyError(resolvedClickPlan.mouseSequenceError); return; } if (resolvedClickPlan.error) { replyError(resolvedClickPlan.error); return; } reply({ result: { type: 'string', value: JSON.stringify({ resolvedOffsetX: offsetX ?? 50, resolvedOffsetY: offsetY ?? 10, button, clickCount, }), }, }); return; } if ( expression.includes('getComputedStyle(element)') && (expression.includes('boundingClientRect') || expression.includes('visibleClip') || expression.includes('centerPoint') || expression.includes('querySelectorAll(selector)')) ) { const selector = parseJsonArgument(expression, 'selector') || ''; const nth = parseNumberArgument(expression, 'nth'); const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; const pierceShadow = expression.includes('const pierceShadow = true'); const frame = frameSelector ? getMockFrame(page, frameSelector) : undefined; const shadowRoot = pierceShadow ? getMockShadowRoot(page) : undefined; const scopedQuery = frame?.query?.[selector] ?? shadowRoot?.query?.[selector]; const frameRootPlan = frame?.query?.[frameSelector]; const frameRect = !Array.isArray(frameRootPlan) ? frameRootPlan?.rect : undefined; const applyFrameOffset = (rect?: MockRect): MockRect | undefined => { if (!rect || !frameRect) { return rect; } return { x: rect.x + frameRect.left, y: rect.y + frameRect.top, width: rect.width, height: rect.height, top: rect.top + frameRect.top, right: rect.right + frameRect.left, bottom: rect.bottom + frameRect.top, left: rect.left + frameRect.left, }; }; const queryPlan = scopedQuery ?? shiftSelectorSnapshot(page, selector); if ( page.screenshot?.requireScrolledMeasurement && (selector in (page.query || {}) || Boolean(scopedQuery)) && !expression.includes('scrollIntoView') ) { replyError(`scrollIntoView required for selector: ${selector}`); return; } if (Array.isArray(queryPlan) && expression.includes('querySelectorAll(selector)')) { const targetIndex = nth ?? 0; const target = queryPlan[targetIndex]; if (target?.error) { replyError(target.error); return; } if (queryPlan.length <= targetIndex || target?.exists === false || !target) { reply({ result: { type: 'string', value: JSON.stringify({ exists: nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, count: queryPlan.length, targetIndex, targetMissing: true, }), }, }); return; } const rect = applyFrameOffset(target.rect); const text = target.innerText || target.textContent || ''; reply({ result: { type: 'string', value: JSON.stringify({ exists: true, count: queryPlan.length, targetIndex, connected: target.connected !== false, text, innerText: target.innerText || '', textContent: target.textContent || '', boundingClientRect: rect, display: target.display || 'block', visibility: target.visibility || 'visible', opacity: target.opacity || '1', href: target.href || '', onclick: target.onclick || '', interactable: target.connected !== false && (target.display || 'block') !== 'none' && (target.visibility || 'visible') !== 'hidden' && Boolean(rect && rect.width > 0 && rect.height > 0), }), }, }); return; } const resolvedQueryPlan = Array.isArray(queryPlan) ? queryPlan.shift() : queryPlan; if (resolvedQueryPlan?.error) { replyError(resolvedQueryPlan.error); return; } if (resolvedQueryPlan?.exists === false || !resolvedQueryPlan) { reply({ result: { type: 'string', value: JSON.stringify({ exists: false }) } }); return; } const rect = applyFrameOffset(resolvedQueryPlan.rect); const text = resolvedQueryPlan.innerText || resolvedQueryPlan.textContent || ''; const viewportWidth = 1280; const viewportHeight = 720; const clipX = Math.max(0, rect?.left ?? 0); const clipY = Math.max(0, rect?.top ?? 0); const clipRight = Math.min(viewportWidth, rect?.right ?? 0); const clipBottom = Math.min(viewportHeight, rect?.bottom ?? 0); reply({ result: { type: 'string', value: JSON.stringify({ exists: true, connected: resolvedQueryPlan.connected !== false, text, innerText: resolvedQueryPlan.innerText || '', textContent: resolvedQueryPlan.textContent || '', boundingClientRect: rect, display: resolvedQueryPlan.display || 'block', visibility: resolvedQueryPlan.visibility || 'visible', opacity: resolvedQueryPlan.opacity || '1', interactable: resolvedQueryPlan.connected !== false && (resolvedQueryPlan.display || 'block') !== 'none' && (resolvedQueryPlan.visibility || 'visible') !== 'hidden' && Boolean(rect && rect.width > 0 && rect.height > 0), centerPoint: rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, } : undefined, visibleClip: rect ? { x: clipX, y: clipY, width: Math.max(0, clipRight - clipX), height: Math.max(0, clipBottom - clipY), scale: 1, } : undefined, }), }, }); return; } if ( expression.includes("dispatch('mouseover')") && expression.includes("dispatch('mouseenter')") && expression.includes("dispatch('mousemove')") ) { const selector = parseJsonArgument(expression, 'selector') || ''; const hoverPlan = page.hover?.[selector]; if (!hoverPlan) { replyError(`element not found for selector: ${selector}`); return; } if (hoverPlan.detached) { replyError(`element is detached for selector: ${selector}`); return; } if (hoverPlan.hidden || hoverPlan.zeroSized) { replyError(`element is hidden or not interactable for selector: ${selector}`); return; } if (hoverPlan.requireCdpMouseMove && !hoverPlan.lastMouseMove) { replyError(`real mouse movement required for selector: ${selector}`); return; } if (hoverPlan.error) { replyError(hoverPlan.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; } if (expression.includes('new KeyboardEvent(')) { const key = parseJsonArgument(expression, 'key') || ''; const repeat = parseNumberArgument(expression, 'repeat') ?? 1; const modifiersMatch = expression.match(/const modifiers = (\[[^\n;]*\]);/); const modifiers = modifiersMatch ? (JSON.parse(modifiersMatch[1]) as string[]) : []; const keyboardPlan = page.keyboard; if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { replyError(`unexpected key: ${key}`); return; } if ( keyboardPlan?.expectedModifiers && JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) ) { replyError(`unexpected modifiers for key: ${key}`); return; } if ( typeof keyboardPlan?.expectedRepeat === 'number' && repeat !== keyboardPlan.expectedRepeat ) { replyError(`unexpected repeat for key: ${key}`); return; } reply({ result: { type: 'string', value: JSON.stringify({ key, modifiers, repeat }), }, }); return; } if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) { const selector = parseJsonArgument(expression, 'selector'); const behavior = parseJsonArgument(expression, 'behavior') || ''; const deltaX = Number(expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); const deltaY = Number(expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); const scrollPlan = selector ? page.scroll?.[selector] : undefined; if (selector && !scrollPlan) { replyError(`element not found for selector: ${selector}`); return; } if (scrollPlan?.expectedBehavior && behavior !== scrollPlan.expectedBehavior) { replyError(`unexpected scroll behavior for selector: ${selector}`); return; } if ( typeof scrollPlan?.expectedDeltaX === 'number' && deltaX !== scrollPlan.expectedDeltaX ) { replyError(`unexpected deltaX for selector: ${selector}`); return; } if ( typeof scrollPlan?.expectedDeltaY === 'number' && deltaY !== scrollPlan.expectedDeltaY ) { replyError(`unexpected deltaY for selector: ${selector}`); return; } reply({ result: { type: 'string', value: JSON.stringify( selector ? { scope: 'element', selector, behavior, deltaX, deltaY } : { scope: 'page', behavior, deltaX, deltaY } ), }, }); 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, options.responseTimeoutMs ); 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)); } child.stdin.end(); 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_press_key', 'browser_scroll', 'browser_select_page', 'browser_open_page', 'browser_close_page', 'browser_add_intercept_rule', 'browser_remove_intercept_rule', 'browser_list_intercept_rules', 'browser_list_requests', 'browser_set_download_behavior', 'browser_list_downloads', 'browser_cancel_download', 'browser_set_file_input', 'browser_drag_files', 'browser_drag_element', 'browser_pointer_action', 'browser_start_recording', 'browser_stop_recording', 'browser_get_recording', 'browser_clear_recording', 'browser_start_replay', 'browser_get_replay', 'browser_cancel_replay', 'browser_start_orchestration', 'browser_get_orchestration', 'browser_cancel_orchestration', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', 'browser_hover', 'browser_query', 'browser_take_element_screenshot', 'browser_wait_for_event', ]); const clickTool = tools.find((tool) => tool.name === 'browser_click'); expect(clickTool?.description).toContain('mouse event chain'); expect(clickTool?.description).not.toContain('synthetic element.click()'); expect(clickTool?.inputSchema?.properties?.offsetX).toMatchObject({ type: 'number' }); expect(clickTool?.inputSchema?.properties?.offsetY).toMatchObject({ type: 'number' }); expect(clickTool?.inputSchema?.properties?.button).toMatchObject({ type: 'string' }); expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({ type: 'integer', minimum: 1, }); const keyTool = tools.find((tool) => tool.name === 'browser_press_key'); expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' }); expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' }); const scrollTool = tools.find((tool) => tool.name === 'browser_scroll'); expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' }); expect(scrollTool?.inputSchema?.properties?.deltaY).toMatchObject({ type: 'number' }); const selectTool = tools.find((tool) => tool.name === 'browser_select_page'); expect(selectTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(selectTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); const openTool = tools.find((tool) => tool.name === 'browser_open_page'); expect(openTool?.inputSchema?.properties?.url).toMatchObject({ type: 'string' }); const closeTool = tools.find((tool) => tool.name === 'browser_close_page'); expect(closeTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(closeTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); const addRuleTool = tools.find((tool) => tool.name === 'browser_add_intercept_rule'); expect(addRuleTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(addRuleTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.urlIncludes).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.method).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.resourceType).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.urlPattern).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.urlRegex).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.headerMatchers).toMatchObject({ type: 'array' }); expect(addRuleTool?.inputSchema?.properties?.priority).toMatchObject({ type: 'integer' }); expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.statusCode).toMatchObject({ type: 'integer' }); expect(addRuleTool?.inputSchema?.properties?.responseHeaders).toMatchObject({ type: 'array' }); expect(addRuleTool?.inputSchema?.properties?.headers).toBeUndefined(); expect(addRuleTool?.inputSchema?.properties?.body).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.contentType).toMatchObject({ type: 'string' }); const removeRuleTool = tools.find((tool) => tool.name === 'browser_remove_intercept_rule'); expect(removeRuleTool?.inputSchema?.properties?.ruleId).toMatchObject({ type: 'string' }); const listRulesTool = tools.find((tool) => tool.name === 'browser_list_intercept_rules'); expect(listRulesTool?.inputSchema).toMatchObject({ type: 'object' }); const listRequestsTool = tools.find((tool) => tool.name === 'browser_list_requests'); expect(listRequestsTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(listRequestsTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); expect(listRequestsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior'); expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' }); expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' }); expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' }); const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads'); expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined(); const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download'); expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' }); expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' }); const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input'); expect(uploadTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); expect(uploadTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); expect(uploadTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(uploadTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); expect(uploadTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); expect(uploadTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); expect(uploadTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); const dragFilesTool = tools.find((tool) => tool.name === 'browser_drag_files'); expect(dragFilesTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); expect(dragFilesTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); expect(dragFilesTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(dragFilesTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); expect(dragFilesTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); expect(dragFilesTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); expect(dragFilesTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); const dragElementTool = tools.find((tool) => tool.name === 'browser_drag_element'); expect(dragElementTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ type: 'string' }); expect(dragElementTool?.inputSchema?.properties?.targetX).toMatchObject({ type: 'number' }); expect(dragElementTool?.inputSchema?.properties?.targetY).toMatchObject({ type: 'number' }); expect(dragElementTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'integer' }); 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 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 startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); const queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', }); for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer', minimum: 0, }); } }); it('marks the selected page in browser_get_session_info', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, ], [ { jsonrpc: '2.0', id: 801, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 802, method: 'tools/call', params: { name: 'browser_get_session_info', arguments: {} }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 802)); expect(text).toContain('selected: true'); expect(text).toContain('1. Docs'); }); it('uses the selected page when pageIndex is omitted', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', visibleText: 'Home text', }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs', visibleText: 'Docs text', }, ], [ { jsonrpc: '2.0', id: 811, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 812, method: 'tools/call', params: { name: 'browser_get_visible_text', arguments: {} }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 812)); expect(text).toContain('Docs text'); }); it('opens a page and makes it selected', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 821, method: 'tools/call', params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' }, }, }, { jsonrpc: '2.0', id: 822, method: 'tools/call', params: { name: 'browser_get_session_info', arguments: {} }, }, ] ); const openText = getResponseText(responses.find((message) => message.id === 821)); expect(openText).toContain('status: opened'); expect(openText).toContain('url: https://example.com/new'); const listText = getResponseText(responses.find((message) => message.id === 822)); expect(listText).toContain('https://example.com/new'); expect(listText).toContain('selected: true'); }); it('closes the selected page and falls back deterministically', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, ], [ { jsonrpc: '2.0', id: 831, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 832, method: 'tools/call', params: { name: 'browser_close_page', arguments: {} }, }, { jsonrpc: '2.0', id: 833, method: 'tools/call', params: { name: 'browser_get_session_info', arguments: {} }, }, ] ); const closeText = getResponseText(responses.find((message) => message.id === 832)); expect(closeText).toContain('status: closed'); const listText = getResponseText(responses.find((message) => message.id === 833)); expect(listText).toContain('0. Home'); expect(listText).toContain('selected: true'); expect(listText).not.toContain('Docs'); }); it('keeps the selected page when closing a different page', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', visibleText: 'Home text', }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs', visibleText: 'Docs text', }, { id: 'page-3', title: 'Pricing', currentUrl: 'https://example.com/pricing', visibleText: 'Pricing text', }, ], [ { jsonrpc: '2.0', id: 834, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, }, { jsonrpc: '2.0', id: 835, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-3' } }, }, { jsonrpc: '2.0', id: 836, method: 'tools/call', params: { name: 'browser_get_visible_text', arguments: {} }, }, { jsonrpc: '2.0', id: 837, method: 'tools/call', params: { name: 'browser_get_session_info', arguments: {} }, }, ] ); const visibleText = getResponseText(responses.find((message) => message.id === 836)); expect(visibleText).toContain('Home text'); const listText = getResponseText(responses.find((message) => message.id === 837)); expect(listText).toContain('0. Home'); expect(listText).toContain('selected: true'); expect(listText).not.toContain('Pricing'); }); it('reconciles a stale selected page when browser_get_session_info is called', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 841, method: 'tools/call', params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, }, { jsonrpc: '2.0', id: 842, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, }, { jsonrpc: '2.0', id: 843, method: 'tools/call', params: { name: 'browser_get_session_info', arguments: {} }, }, ] ); const listText = getResponseText(responses.find((message) => message.id === 843)); expect(listText).toContain('0. Home'); expect(listText).toContain('selected: true'); }); it('closes a page by pageId', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 851, method: 'tools/call', params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, }, { jsonrpc: '2.0', id: 852, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, }, { jsonrpc: '2.0', id: 853, method: 'tools/call', params: { name: 'browser_get_session_info', arguments: {} }, }, ] ); const closeText = getResponseText(responses.find((message) => message.id === 852)); expect(closeText).toContain('pageId: page-2'); expect(closeText).toContain('status: closed'); const listText = getResponseText(responses.find((message) => message.id === 853)); expect(listText).toContain('0. Home'); expect(listText).not.toContain('https://example.com/new'); }); it('adds an interception rule and lists it by bound pageId', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, ], [ { jsonrpc: '2.0', id: 901, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 }, }, }, { jsonrpc: '2.0', id: 902, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, }, }, { jsonrpc: '2.0', id: 903, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {}, }, }, ] ); const listText = getResponseText(responses.find((message) => message.id === 903)); expect(listText).toContain('pageId: page-2'); expect(listText).toContain('urlIncludes: /api'); expect(listText).toContain('method: GET'); expect(listText).toContain('action: continue'); }); it('keeps an existing rule bound to the original page after selected page changes', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, ], [ { jsonrpc: '2.0', id: 911, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, }, { jsonrpc: '2.0', id: 912, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', method: 'POST', action: 'fail' }, }, }, { jsonrpc: '2.0', id: 913, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 914, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, ] ); const listText = getResponseText(responses.find((message) => message.id === 914)); expect(listText).toContain('pageId: page-1'); expect(listText).toContain('method: POST'); expect(listText).toContain('action: fail'); }); it('keeps richer matching rules bound to the original page after selected page changes', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, ], [ { jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { resourceType: 'XHR', priority: 7, action: 'continue', }, }, }, { jsonrpc: '2.0', id: 1005, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, }, { jsonrpc: '2.0', id: 1006, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, ] ); const listText = getResponseText(responses.find((message) => message.id === 1006)); expect(listText).toContain('pageId: page-2'); expect(listText).toContain('resourceType: XHR'); expect(listText).toContain('priority: 7'); }); it('removes an interception rule by ruleId', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 921, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, }, }, { jsonrpc: '2.0', id: 922, method: 'tools/call', params: { name: 'browser_remove_intercept_rule', arguments: { ruleId: 'rule-1' }, }, }, { jsonrpc: '2.0', id: 923, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, ] ); const removeText = getResponseText(responses.find((message) => message.id === 922)); expect(removeText).toContain('ruleId: rule-1'); expect(removeText).toContain('status: removed'); const listText = getResponseText(responses.find((message) => message.id === 923)); expect(listText).toBe('status: empty'); }); it('records recent requests with matched rule action summaries', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-1', url: 'https://example.com/api/users', method: 'GET', resourceType: 'XHR', }, { requestId: 'req-2', url: 'https://example.com/assets/app.js', method: 'GET', resourceType: 'Script', }, ], }, }, ], [ { jsonrpc: '2.0', id: 931, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', method: 'GET', action: 'fail' }, }, }, { jsonrpc: '2.0', id: 932, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 932)); expect(listText).toContain('requestId: req-1'); expect(listText).toContain('matchedRuleId: rule-1'); expect(listText).toContain('action: fail'); expect(listText).toContain('requestId: req-2'); expect(listText).toContain('action: continue'); }); it('removes rules and recent requests bound to a page after that page is closed', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs', intercept: { pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], }, }, ], [ { jsonrpc: '2.0', id: 941, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 942, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, }, }, { jsonrpc: '2.0', id: 943, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, { jsonrpc: '2.0', id: 944, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, }, { jsonrpc: '2.0', id: 945, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, { jsonrpc: '2.0', id: 946, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const preCloseRequestsText = getResponseText(responses.find((message) => message.id === 943)); expect(preCloseRequestsText).toContain('requestId: req-closed'); const listRulesText = getResponseText(responses.find((message) => message.id === 945)); expect(listRulesText).not.toContain('pageId: page-2'); const postCloseRequestsText = getResponseText(responses.find((message) => message.id === 946)); expect(postCloseRequestsText).not.toContain('requestId: req-closed'); expect(postCloseRequestsText).not.toContain('pageId: page-2'); }); it('rejects browser_add_intercept_rule when pageIndex and pageId are both provided', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 951, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { pageIndex: 0, pageId: 'page-1', urlIncludes: '/api', action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 951); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together'); }); it('rejects browser_add_intercept_rule when action is invalid', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 952, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', action: 'mock', }, }, }, ] ); const response = responses.find((message) => message.id === 952); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); }); it('rejects intercept rules when urlPattern and urlRegex are both provided', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 981, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlPattern: 'https://example.com/api/*', urlRegex: '^https://example\\.com/api/', action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 981); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: urlPattern and urlRegex cannot be used together'); }); it('rejects intercept rules when priority is not an integer', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 982, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', priority: 1.5, action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 982); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: priority must be an integer'); }); it('rejects intercept rules when headerMatchers is not an array', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 983, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { headerMatchers: 'x', action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 983); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers must be an array'); }); it('rejects intercept rules when a header matcher is missing name', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 984, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { headerMatchers: [{ valueIncludes: 'staging' }], action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 984); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.name is required'); }); it('rejects intercept rules when a header matcher has no value matcher', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 985, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { headerMatchers: [{ name: 'x-env' }], action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 985); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex'); }); it('rejects intercept rules when no matching condition is provided', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 986, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { priority: 10, action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 986); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: at least one matching condition is required'); }); it('adds a resourceType interception rule and lists its richer matching summary', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 987, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { resourceType: 'XHR', priority: 10, action: 'continue', }, }, }, { jsonrpc: '2.0', id: 988, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, ] ); const listText = getResponseText(responses.find((message) => message.id === 988)); expect(listText).toContain('resourceType: XHR'); expect(listText).toContain('priority: 10'); }); it('prefers the higher-priority matched rule over an earlier lower-priority rule', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-priority', url: 'https://example.com/api/orders', method: 'GET', resourceType: 'XHR', }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 989, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', action: 'fulfill', statusCode: 201, body: 'low', }, }, }, { jsonrpc: '2.0', id: 990, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { resourceType: 'XHR', priority: 10, action: 'fulfill', statusCode: 202, body: 'high', }, }, }, { jsonrpc: '2.0', id: 991, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 991)); expect(listText).toContain('requestId: req-priority'); expect(listText).toContain('matchedRuleId: rule-2'); expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(202); }); it('keeps creation order when matched rules have the same priority', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-same-priority', url: 'https://example.com/api/orders', method: 'GET', resourceType: 'XHR', }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 9911, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', priority: 5, action: 'fulfill', statusCode: 201, body: 'first', }, }, }, { jsonrpc: '2.0', id: 9912, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { resourceType: 'XHR', priority: 5, action: 'fulfill', statusCode: 202, body: 'second', }, }, }, { jsonrpc: '2.0', id: 9913, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 9913)); expect(listText).toContain('requestId: req-same-priority'); expect(listText).toContain('matchedRuleId: rule-1'); expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(201); }); it('matches urlPattern rules with wildcard syntax', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-pattern', url: 'https://example.com/api/v1/users', method: 'GET', }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 992, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlPattern: 'https://example.com/api/*', action: 'fail', }, }, }, { jsonrpc: '2.0', id: 993, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 993)); expect(listText).toContain('requestId: req-pattern'); expect(listText).toContain('action: fail'); expect(pages[0]?.intercept?.failedRequests?.[0]?.requestId).toBe('req-pattern'); }); it('matches urlRegex rules', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-regex', url: 'https://example.com/api/users', method: 'GET', }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 994, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlRegex: '^https://example\\.com/api/(users|teams)$', action: 'continue', }, }, }, { jsonrpc: '2.0', id: 995, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 995)); expect(listText).toContain('requestId: req-regex'); expect(listText).toContain('matchedRuleId: rule-1'); expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-regex'); }); it('matches headerMatchers using valueIncludes and case-insensitive header names', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-header-includes', url: 'https://example.com/api/header-includes', method: 'GET', requestHeaders: { 'X-Env': 'staging-us', }, }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 996, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { headerMatchers: [{ name: 'x-env', valueIncludes: 'staging' }], action: 'fulfill', statusCode: 207, body: 'matched-includes', }, }, }, { jsonrpc: '2.0', id: 997, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 997)); expect(listText).toContain('requestId: req-header-includes'); expect(listText).toContain('matchedRuleId: rule-1'); expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(207); }); it('matches headerMatchers using valueRegex', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-header-regex', url: 'https://example.com/api/header-regex', method: 'GET', requestHeaders: { 'x-tenant': 'acme-prod', }, }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 998, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { headerMatchers: [{ name: 'X-Tenant', valueRegex: '^acme-' }], action: 'continue', }, }, }, { jsonrpc: '2.0', id: 999, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 999)); expect(listText).toContain('requestId: req-header-regex'); expect(listText).toContain('matchedRuleId: rule-1'); expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-header-regex'); }); it('adds a fulfill interception rule and lists its response summary', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 961, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock', method: 'GET', action: 'fulfill', statusCode: 202, contentType: 'application/json', body: '{"ok":true}', }, }, }, { jsonrpc: '2.0', id: 962, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, ] ); const listText = getResponseText(responses.find((message) => message.id === 962)); expect(listText).toContain('action: fulfill'); expect(listText).toContain('statusCode: 202'); expect(listText).toContain('contentType: application/json'); }); it('fulfills a paused request with the configured mock response', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-fulfill-1', url: 'https://example.com/api/mock/users', method: 'GET', resourceType: 'XHR', }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 963, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock', method: 'GET', action: 'fulfill', statusCode: 200, contentType: 'application/json', body: '{"users":[1]}', }, }, }, { jsonrpc: '2.0', id: 964, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 964)); expect(listText).toContain('requestId: req-fulfill-1'); expect(listText).toContain('matchedRuleId: rule-1'); expect(listText).toContain('action: fulfill'); expect(pages[0]?.intercept?.fulfilledRequests).toEqual([ expect.objectContaining({ requestId: 'req-fulfill-1', responseCode: 200, }), ]); }); it('passes custom response headers to Fetch.fulfillRequest', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-fulfill-2', url: 'https://example.com/api/mock/headers', method: 'GET', }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 965, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock/headers', action: 'fulfill', statusCode: 201, responseHeaders: [ { name: 'Cache-Control', value: 'no-store' }, { name: 'X-Mocked-By', value: 'ccs-browser' }, ], body: 'ok', }, }, }, { jsonrpc: '2.0', id: 966, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 966)); expect(listText).toContain('action: fulfill'); expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseHeaders).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'Cache-Control', value: 'no-store' }), expect.objectContaining({ name: 'X-Mocked-By', value: 'ccs-browser' }), ]) ); }); it('allows fulfill rules with an empty response body', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 967, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/empty', action: 'fulfill', statusCode: 204, contentType: 'text/plain', body: '', }, }, }, ] ); const addText = getResponseText(responses.find((message) => message.id === 967)); expect(addText).toContain('action: fulfill'); expect(addText).toContain('statusCode: 204'); }); it('rejects fulfill rules when statusCode is out of range', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 968, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock', action: 'fulfill', statusCode: 99, body: 'x', }, }, }, ] ); const response = responses.find((message) => message.id === 968); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); }); it('rejects fulfill rules when responseHeaders is not an array', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 969, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock', action: 'fulfill', responseHeaders: 'x', body: 'x', }, }, }, ] ); const response = responses.find((message) => message.id === 969); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array'); }); it('rejects fulfill rules when a responseHeaders entry is missing name', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 970, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock', action: 'fulfill', responseHeaders: [{ value: 'x' }], body: 'x', }, }, }, ] ); const response = responses.find((message) => message.id === 970); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required'); }); it('rejects fulfill rules when body is not a string', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 971, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock', action: 'fulfill', body: 123, }, }, }, ] ); const response = responses.find((message) => message.id === 971); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: body must be a string'); }); it('rejects intercept rules when urlRegex is invalid', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlRegex: '[', action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 1001); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: urlRegex must be a valid regular expression'); }); it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { headerMatchers: [{ name: 'x-env', valueRegex: '[' }], action: 'continue', }, }, }, ] ); const response = responses.find((message) => message.id === 1002); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression'); }); it('removes fulfill rules and request summaries after the bound page is closed', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, { id: 'page-2', title: 'Mocked', currentUrl: 'https://example.com/mocked', intercept: { pausedRequests: [ { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, ], }, }, ], [ { jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } }, { jsonrpc: '2.0', id: 973, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api/mock/close', action: 'fulfill', statusCode: 200, body: 'done', }, }, }, { jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, { jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } }, { jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } }, { jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, ], { responseTimeoutMs: 12000, } ); expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close'); expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2'); expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close'); }); it('returns only the requested number of recent requests', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { pausedRequests: [ { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, ], }, }, ], [ { jsonrpc: '2.0', id: 953, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, }, }, { jsonrpc: '2.0', id: 954, method: 'tools/call', params: { name: 'browser_list_requests', arguments: { limit: 1 } }, }, ], { responseTimeoutMs: 12000, } ); const listText = getResponseText(responses.find((message) => message.id === 954)); expect(listText).toContain('requestId: req-2'); expect(listText).not.toContain('requestId: req-1'); }); it('fails browser_add_intercept_rule when Fetch.enable fails', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/', intercept: { enableError: 'Fetch.enable blocked', }, }, ], [ { jsonrpc: '2.0', id: 955, method: 'tools/call', params: { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', action: 'continue' }, }, }, { jsonrpc: '2.0', id: 956, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, ], { responseTimeoutMs: 12000, } ); const addResponse = responses.find((message) => message.id === 955); expect((addResponse?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(addResponse)).toContain('Browser MCP failed: Fetch.enable blocked'); const listText = getResponseText(responses.find((message) => message.id === 956)); expect(listText).toBe('status: empty'); }); 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: resolveNodeModulesPath(), }, responseTimeoutMs: 12000, } ); 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 }); } }, 10000); 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 index 0 is out of range 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('clicks the requested zero-based match and rejects out-of-range nth', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Click Page', currentUrl: 'https://example.com/', click: { '.menu-item': [{ label: 'first' }, { label: 'second' }], }, }, ], [ { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 1 } }, }, { jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 3 } }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 20))).toContain( 'status: clicked' ); expect(getResponseText(responses.find((message) => message.id === 20))).toContain('nth: 1'); expect(getResponseText(responses.find((message) => message.id === 21))).toContain( 'Browser MCP failed: element index 3 is out of range for selector: .menu-item' ); }); 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('clicks with element-relative offsets and richer click options', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Click Page', currentUrl: 'https://example.com/', click: { '#offset-target': { requireMouseSequence: true, expectedOffset: { x: 12, y: 8 }, expectedButton: 'right', expectedClickCount: 2, }, '#double-left': { requireMouseSequence: true, expectedButton: 'left', expectedClickCount: 2, requireDoubleClickEvent: true, }, }, }, ], [ { jsonrpc: '2.0', id: 70, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#offset-target', offsetX: 12, offsetY: 8, button: 'right', clickCount: 2, }, }, }, { jsonrpc: '2.0', id: 700, method: 'tools/call', params: { name: 'browser_click', arguments: { selector: '#double-left', clickCount: 2, }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 70)); expect(text).toContain('status: clicked'); expect(text).toContain('offsetX: 12'); expect(text).toContain('offsetY: 8'); expect(text).toContain('button: right'); expect(text).toContain('clickCount: 2'); const doubleText = getResponseText(responses.find((message) => message.id === 700)); expect(doubleText).toContain('status: clicked'); expect(doubleText).toContain('button: left'); expect(doubleText).toContain('clickCount: 2'); }); it('presses a key combination with browser_press_key', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Keyboard Page', currentUrl: 'https://example.com/', keyboard: { expectedKey: 'k', expectedModifiers: ['Meta'], expectedRepeat: 2, }, }, ], [ { jsonrpc: '2.0', id: 71, method: 'tools/call', params: { name: 'browser_press_key', arguments: { key: 'k', modifiers: ['Meta'], repeat: 2, }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 71)); expect(text).toContain('status: key-pressed'); expect(text).toContain('key: k'); expect(text).toContain('modifiers: Meta'); expect(text).toContain('repeat: 2'); }); it('supports common special keys with browser_press_key', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Special Key Page', currentUrl: 'https://example.com/', keyboard: { expectedKey: 'Enter', expectedRepeat: 1, }, }, ], [ { jsonrpc: '2.0', id: 711, method: 'tools/call', params: { name: 'browser_press_key', arguments: { key: 'Enter', }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 711)); expect(text).toContain('status: key-pressed'); expect(text).toContain('key: Enter'); }); it('rejects unsupported modifiers for browser_press_key', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Invalid Modifier Page', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 712, method: 'tools/call', params: { name: 'browser_press_key', arguments: { key: 'k', modifiers: ['Cmd'], }, }, }, ] ); const response = responses.find((message) => message.id === 712); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain( 'modifiers must only contain: Alt, Control, Meta, Shift' ); }); it('scrolls an element into view with browser_scroll', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Scroll Page', currentUrl: 'https://example.com/', scroll: { '#results': { expectedBehavior: 'into-view', }, }, }, ], [ { jsonrpc: '2.0', id: 72, method: 'tools/call', params: { name: 'browser_scroll', arguments: { selector: '#results', behavior: 'into-view', }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 72)); expect(text).toContain('status: scrolled'); expect(text).toContain('selector: #results'); expect(text).toContain('behavior: into-view'); }); it('scrolls an iframe page by offset with browser_scroll', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Iframe Scroll Page', currentUrl: 'https://example.com/', frames: [ { selector: '#preview-frame', query: { '#preview-frame': { exists: true, }, }, }, ], }, ], [ { jsonrpc: '2.0', id: 73, method: 'tools/call', params: { name: 'browser_scroll', arguments: { frameSelector: '#preview-frame', behavior: 'by-offset', deltaX: 5, deltaY: 40, }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 73)); expect(text).toContain('status: scrolled'); expect(text).toContain('behavior: by-offset'); expect(text).toContain('deltaX: 5'); expect(text).toContain('deltaY: 40'); }); it('requires real mouse movement for hover-only targets and reports hover failures', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Hover Page', currentUrl: 'https://example.com/', hover: { '.draft-card': { requireCdpMouseMove: true }, '.missing-bounds': { zeroSized: true }, }, query: { '.draft-card': { exists: true, connected: true, rect: { x: 100, y: 40, width: 120, height: 48, top: 40, right: 220, bottom: 88, left: 100, }, display: 'block', visibility: 'visible', opacity: '1', }, '.missing-bounds': { exists: true, connected: true, rect: { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, }, display: 'block', visibility: 'visible', opacity: '1', }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_hover', arguments: { selector: '.draft-card' }, }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_hover', arguments: { selector: '.missing-bounds' }, }, }, ] ); const hoverResponse = responses.find((message) => message.id === 2); expect((hoverResponse?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(hoverResponse)).toContain('status: hovered'); expect(getResponseText(hoverResponse)).toContain('selector: .draft-card'); const hoverError = responses.find((message) => message.id === 3); expect((hoverError?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(hoverError)).toContain( 'element is hidden or not interactable for selector: .missing-bounds' ); }); it('queries element diagnostics and validates query fields', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Query Page', currentUrl: 'https://example.com/', query: { '.edit-button': { exists: true, connected: true, innerText: 'Edit', textContent: 'Edit', rect: { x: 123, y: 45, width: 48, height: 24, top: 45, right: 171, bottom: 69, left: 123, }, display: 'block', visibility: 'visible', opacity: '1', }, '.missing-button': { exists: false, }, }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.edit-button' }, }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.edit-button', fields: ['display', 'visibility', 'opacity'], }, }, }, { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.missing-button' }, }, }, { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.edit-button', fields: 'display' }, }, }, { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.edit-button', fields: ['displayMode'] }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain( 'exists: true' ); expect(getResponseText(responses.find((message) => message.id === 2))).toContain( 'boundingClientRect: {"x":123' ); expect(getResponseText(responses.find((message) => message.id === 3))).toContain( 'display: block' ); expect(getResponseText(responses.find((message) => message.id === 3))).not.toContain( 'innerText:' ); const missingResponse = responses.find((message) => message.id === 4); expect((missingResponse?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(missingResponse)).toContain('exists: false'); expect(getResponseText(responses.find((message) => message.id === 5))).toContain( 'Browser MCP failed: fields must be an array of strings' ); expect(getResponseText(responses.find((message) => message.id === 6))).toContain( 'Browser MCP failed: unknown query field: displayMode' ); }); it('reports count, nth-aware fields, href, and onclick for multi-match selectors', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Query Page', currentUrl: 'https://example.com/', query: { '.hover-action': [ { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1', href: 'https://example.com/open', onclick: 'openCard()', }, { exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '0.95', href: 'https://example.com/archive', onclick: 'archiveCard()', }, ], }, }, ], [ { jsonrpc: '2.0', id: 30, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.hover-action', nth: 1, fields: ['count', 'exists', 'innerText', 'href', 'onclick'], }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 30)); expect(text).toContain('count: 2'); expect(text).toContain('exists: true'); expect(text).toContain('innerText: Archive'); expect(text).toContain('href: https://example.com/archive'); expect(text).toContain('onclick: archiveCard()'); expect(text).toContain('nth: 1'); }); it('keeps count and exists available when nth is out of range, but rejects target fields', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Query Page', currentUrl: 'https://example.com/', query: { '.hover-action': [ { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1', }, ], }, }, ], [ { jsonrpc: '2.0', id: 31, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.hover-action', nth: 3, fields: ['count', 'exists'], }, }, }, { jsonrpc: '2.0', id: 32, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.hover-action', nth: 3, fields: ['count', 'innerText'], }, }, }, ] ); const countOnly = responses.find((message) => message.id === 31); expect((countOnly?.result as { isError?: boolean }).isError).not.toBe(true); expect(getResponseText(countOnly)).toContain('count: 1'); expect(getResponseText(countOnly)).toContain('exists: false'); expect(getResponseText(responses.find((message) => message.id === 32))).toContain( 'Browser MCP failed: element index 3 is out of range for selector: .hover-action' ); }); it('waits for selector visibility with opacity threshold', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Wait Page', currentUrl: 'https://example.com/', wait: { selectorSnapshots: { '.hover-action': [ { exists: false }, { exists: true, connected: true, display: 'block', visibility: 'visible', opacity: '0.95', rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10, }, }, ], }, }, }, ], [ { jsonrpc: '2.0', id: 40, method: 'tools/call', params: { name: 'browser_wait_for', arguments: { selector: '.hover-action', timeoutMs: 1000, pollIntervalMs: 10, condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 40))).toContain( 'status: satisfied' ); }); it('waits for page text includes and returns timeout summaries', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Wait Timeout', currentUrl: 'https://example.com/', wait: { pageTextSequence: ['loading', 'loading', 'loaded archive menu'], selectorSnapshots: { '.hover-action': [ { exists: true, connected: true, display: 'block', visibility: 'visible', opacity: '0.2', rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10, }, }, ], }, }, }, ], [ { jsonrpc: '2.0', id: 41, method: 'tools/call', params: { name: 'browser_wait_for', arguments: { timeoutMs: 1000, pollIntervalMs: 10, condition: { kind: 'text', includes: 'archive menu' }, }, }, }, { jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'browser_wait_for', arguments: { selector: '.hover-action', timeoutMs: 30, pollIntervalMs: 10, condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 41))).toContain( 'status: satisfied' ); expect(getResponseText(responses.find((message) => message.id === 42))).toContain( 'Browser MCP failed: wait condition timed out' ); expect(getResponseText(responses.find((message) => message.id === 42))).toContain( 'opacity=0.2' ); }); it('waits for selector text includes', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Wait Text', currentUrl: 'https://example.com/', wait: { selectorSnapshots: { '.hover-action': [ [ { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1', }, ], [ { exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1', }, ], ], }, }, }, ], [ { jsonrpc: '2.0', id: 45, method: 'tools/call', params: { name: 'browser_wait_for', arguments: { selector: '.hover-action', timeoutMs: 1000, pollIntervalMs: 10, condition: { kind: 'text', includes: 'Archive' }, }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 45))).toContain( 'status: satisfied' ); }); it('lets nth become valid later during polling and rejects unsupported page-level wait kinds', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Wait Nth', currentUrl: 'https://example.com/', wait: { selectorSnapshots: { '.hover-action': [ [ { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1', }, ], [ { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1', }, { exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1', rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10, }, }, ], ], }, }, }, ], [ { jsonrpc: '2.0', id: 43, method: 'tools/call', params: { name: 'browser_wait_for', arguments: { selector: '.hover-action', nth: 1, timeoutMs: 1000, pollIntervalMs: 10, condition: { kind: 'visibility', visibility: 'visible' }, }, }, }, { jsonrpc: '2.0', id: 44, method: 'tools/call', params: { name: 'browser_wait_for', arguments: { timeoutMs: 100, pollIntervalMs: 10, condition: { kind: 'visibility', visibility: 'visible' }, }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 43))).toContain( 'status: satisfied' ); expect(getResponseText(responses.find((message) => message.id === 44))).toContain( 'Browser MCP failed: page-level wait only supports text conditions in Phase 1' ); }); it('defaults browser_eval to readonly and enforces readwrite gating', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Eval Page', currentUrl: 'https://example.com/', eval: { 'JSON.stringify({ hovered: true, label: "Archive" })': { result: { hovered: true, label: 'Archive' }, }, 'document.body.dataset.test = "1"': { error: 'EvalError: Possible side-effect in debug-evaluate', }, 'throw new Error("boom")': { error: 'boom', }, window: { nonSerializable: true, }, }, }, ], [ { jsonrpc: '2.0', id: 50, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'JSON.stringify({ hovered: true, label: "Archive" })' }, }, }, { jsonrpc: '2.0', id: 51, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'document.body.dataset.test = "1"' }, }, }, { jsonrpc: '2.0', id: 52, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, }, }, { jsonrpc: '2.0', id: 53, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'throw new Error("boom")' }, }, }, { jsonrpc: '2.0', id: 54, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'window' }, }, }, ], { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readonly' } } ); expect(getResponseText(responses.find((message) => message.id === 50))).toContain( 'mode: readonly' ); expect(getResponseText(responses.find((message) => message.id === 50))).toContain( 'value: {"hovered":true,"label":"Archive"}' ); expect(getResponseText(responses.find((message) => message.id === 51))).toContain( 'Browser MCP failed: EvalError: Possible side-effect in debug-evaluate' ); expect(getResponseText(responses.find((message) => message.id === 52))).toContain( 'Browser MCP failed: browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=readonly' ); expect(getResponseText(responses.find((message) => message.id === 53))).toContain( 'Browser MCP failed: boom' ); expect(getResponseText(responses.find((message) => message.id === 54))).toContain( 'Browser MCP failed: evaluation result is not JSON-serializable' ); }); it('allows readwrite browser_eval when configured', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Eval Page', currentUrl: 'https://example.com/', eval: { 'document.body.dataset.test = "1"': { result: 'ok', }, }, }, ], [ { jsonrpc: '2.0', id: 54, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, }, }, ], { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readwrite' } } ); expect(getResponseText(responses.find((message) => message.id === 54))).toContain( 'mode: readwrite' ); expect(getResponseText(responses.find((message) => message.id === 54))).toContain( 'value: "ok"' ); }); it('queries inside an iframe selected by frameSelector', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Frame Page', currentUrl: 'https://example.com/', frames: [ { selector: '#details-frame', query: { '.save-button': { exists: true, connected: true, innerText: 'Save', textContent: 'Save', display: 'block', visibility: 'visible', opacity: '1', }, }, }, ], }, ], [ { jsonrpc: '2.0', id: 55, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: '.save-button', frameSelector: '#details-frame', fields: ['exists', 'innerText'], }, }, }, ] ); expect( getMockFrame( { frames: [{ selector: '#details-frame' }], id: 'x', title: 'x', currentUrl: 'https://example.com/', }, '#details-frame' ) ).toBeDefined(); expect(getResponseText(responses.find((message) => message.id === 55))).toContain( 'innerText: Save' ); }); it('queries through open shadow roots when pierceShadow is true', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Shadow Page', currentUrl: 'https://example.com/', shadowRoots: [ { hostSelector: 'app-toolbar', query: { 'button[action="archive"]': { exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1', }, }, }, ], }, ], [ { jsonrpc: '2.0', id: 56, method: 'tools/call', params: { name: 'browser_query', arguments: { selector: 'button[action="archive"]', pierceShadow: true, fields: ['exists', 'innerText'], }, }, }, ] ); expect( getMockShadowRoot({ shadowRoots: [{ hostSelector: 'app-toolbar' }], id: 'x', title: 'x', currentUrl: 'https://example.com/', }) ).toBeDefined(); expect(getResponseText(responses.find((message) => message.id === 56))).toContain( 'innerText: Archive' ); }); it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports', browser: {}, events: { downloads: [ { guid: 'download-guid-1', url: 'https://example.com/files/report.csv', suggestedFilename: 'report.csv', progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }], }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 57, method: 'tools/call', params: { name: 'browser_set_download_behavior', arguments: { behavior: 'accept', eventsEnabled: true }, }, }, { jsonrpc: '2.0', id: 58, method: 'tools/call', params: { name: 'browser_list_downloads', arguments: {} }, }, { jsonrpc: '2.0', id: 59, method: 'tools/call', params: { name: 'browser_cancel_download', arguments: { guid: 'download-guid-1' }, }, }, { jsonrpc: '2.0', id: 60, method: 'tools/call', params: { name: 'browser_list_downloads', arguments: {} }, }, ], { responseTimeoutMs: 12000 } ); expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); }); it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }], [ { jsonrpc: '2.0', id: 61, method: 'tools/call', params: { name: 'browser_set_download_behavior', arguments: { behavior: 'deny', downloadPath: '/tmp/blocked-downloads', }, }, }, ] ); const response = responses.find((message) => message.id === 61); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain( 'Browser MCP failed: downloadPath is only allowed when behavior=accept' ); }); it('rejects browser_cancel_download for completed downloads', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports', browser: {}, events: { downloads: [ { guid: 'download-guid-complete', url: 'https://example.com/files/report.csv', suggestedFilename: 'report.csv', progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }], }, ], }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 610, method: 'tools/call', params: { name: 'browser_set_download_behavior', arguments: { behavior: 'accept', eventsEnabled: true }, }, }, { jsonrpc: '2.0', id: 611, method: 'tools/call', params: { name: 'browser_list_downloads', arguments: {} }, }, { jsonrpc: '2.0', id: 612, method: 'tools/call', params: { name: 'browser_cancel_download', arguments: { guid: 'download-guid-complete' }, }, }, ], { responseTimeoutMs: 12000 } ); expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); const response = responses.find((message) => message.id === 612); expect((response?.result as { isError?: boolean }).isError).toBe(true); expect(getResponseText(response)).toContain( 'Browser MCP failed: download is not cancelable in status: completed' ); expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined(); }); it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Event Page', currentUrl: 'https://example.com/', events: { downloads: [ { guid: 'download-guid-2', url: 'https://example.com/files/export.zip', suggestedFilename: 'export.zip', }, ], }, }, ], [ { jsonrpc: '2.0', id: 62, method: 'tools/call', params: { name: 'browser_wait_for_event', arguments: { timeoutMs: 1000, event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' }, }, }, }, ], { responseTimeoutMs: 12000 } ); expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); }); it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); const invoicePath = join(tempDir, 'invoice.pdf'); const receiptPath = join(tempDir, 'receipt.png'); writeFileSync(invoicePath, 'invoice'); writeFileSync(receiptPath, 'receipt'); const pages: MockPageState[] = [ { id: 'page-1', title: 'Root Uploads', currentUrl: 'https://example.com/root', fileInputs: { '#root-upload': { kind: 'file', multiple: true }, }, frames: [ { selector: '#upload-frame', fileInputs: { '#frame-upload': { kind: 'file', multiple: true }, }, }, ], shadowRoots: [ { hostSelector: 'upload-panel', fileInputs: { '#shadow-upload': { kind: 'file' }, }, }, ], }, { id: 'page-2', title: 'Selected Uploads', currentUrl: 'https://example.com/selected', fileInputs: { '#selected-upload': { kind: 'file', multiple: true }, }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 63, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, }, { jsonrpc: '2.0', id: 64, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] }, }, }, { jsonrpc: '2.0', id: 65, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { pageIndex: 0, selector: '#frame-upload', files: [invoicePath], frameSelector: '#upload-frame', }, }, }, { jsonrpc: '2.0', id: 66, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { pageIndex: 0, selector: '#shadow-upload', files: [receiptPath], pierceShadow: true, }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ invoicePath, receiptPath, ]); expect( (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles ).toEqual([invoicePath]); expect( (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles ).toEqual([receiptPath]); }); it('uses pageId for browser_set_file_input when provided', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); const assetPath = join(tempDir, 'asset.txt'); writeFileSync(assetPath, 'asset'); const pages: MockPageState[] = [ { id: 'page-1', title: 'Selected Uploads', currentUrl: 'https://example.com/selected', fileInputs: { '#selected-upload': { kind: 'file' }, }, }, { id: 'page-2', title: 'Explicit Uploads', currentUrl: 'https://example.com/explicit', fileInputs: { '#pageid-upload': { kind: 'file' }, }, }, ]; const responses = await runMcpRequests( pages, [ { jsonrpc: '2.0', id: 67, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } }, }, { jsonrpc: '2.0', id: 68, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ assetPath, ]); expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); }); it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); const okPath = join(tempDir, 'ok.txt'); const missingPath = join(tempDir, 'missing.txt'); writeFileSync(okPath, 'ok'); const responses = await runMcpRequests( [ { id: 'page-1', title: 'Upload Errors', currentUrl: 'https://example.com/', fileInputs: { '#real-file-input': { kind: 'file' }, '#not-file-input': { kind: 'nonfile' }, }, }, ], [ { jsonrpc: '2.0', id: 69, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { selector: '#not-file-input', files: [okPath] }, }, }, { jsonrpc: '2.0', id: 70, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { selector: '#real-file-input', files: [missingPath] }, }, }, { jsonrpc: '2.0', id: 71, method: 'tools/call', params: { name: 'browser_set_file_input', arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 69))).toContain( 'element is not a file input for selector: #not-file-input' ); expect(getResponseText(responses.find((message) => message.id === 70))).toContain( `file does not exist: ${missingPath}` ); expect(getResponseText(responses.find((message) => message.id === 71))).toContain( 'pageIndex and pageId cannot be used together' ); }); 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('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( [ { 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('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('starts orchestration, reports progress, and completes a wait_then_click flow', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Orchestration Page', currentUrl: 'https://example.com/orchestration', click: { '#menu': {}, }, wait: { selectorSnapshots: { '#menu': [ [ { exists: true, rect: { x: 20, y: 30, width: 100, height: 40, top: 30, right: 120, bottom: 70, left: 20, }, display: 'block', visibility: 'visible', opacity: '1', }, ], ], }, }, query: { '#menu': { 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', }, }, }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 1201, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'wait_for_then_click', args: { wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000, }, click: { selector: '#menu', }, }, }), ], }, }, }, { jsonrpc: '2.0', id: 1202, method: 'tools/call', params: { name: 'browser_get_orchestration', arguments: {} }, }, ]); expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed'); expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1'); expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); }); it('completes a wait_then_type flow', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Type Orchestration Page', currentUrl: 'https://example.com/type-orchestration', wait: { selectorSnapshots: { '#email': [ [ { exists: 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: '' }, }, }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 1203, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'wait_for_then_type', args: { wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 }, type: { selector: '#email', text: 'walker@example.com', clearFirst: true }, }, }), ], }, }, }, ]); expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed'); expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1'); }); it('completes a run_replay_sequence block', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Replay Sequence Orchestration Page', currentUrl: 'https://example.com/orchestration-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', }, }, }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 1211, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'run_replay_sequence', args: { steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], }, }), ], }, }, }, ]); expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed'); }); it('completes an assert_query block', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Assert Query Page', currentUrl: 'https://example.com/assert-query', query: { '#status': { exists: true, connected: true, innerText: 'ready', textContent: 'ready', display: 'block', visibility: 'visible', opacity: '1', }, }, }, ], [ { jsonrpc: '2.0', id: 1212, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#status', fields: ['innerText'] }, assert: { field: 'innerText', equals: 'ready' }, }, }), ], }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed'); }); it('fails orchestration on the first failing block', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }], [ { jsonrpc: '2.0', id: 1213, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#missing', fields: ['exists'] }, assert: { field: 'exists', equals: 'true' }, }, }), ], }, }, }, { jsonrpc: '2.0', id: 1214, method: 'tools/call', params: { name: 'browser_get_orchestration', arguments: {} }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 1214)); expect(text).toContain('status: failed'); expect(text).toContain('failedBlockIndex: 0'); }); it('passes assert_query with multiple structured assertions', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Assert Query Page', currentUrl: 'https://example.com/assert-query', query: { '#status': { exists: true, connected: true, innerText: 'ready state', textContent: 'ready state', display: 'block', visibility: 'visible', opacity: '0.95', rect: { x: 20, y: 30, width: 100, height: 40, top: 30, right: 120, bottom: 70, left: 20, }, }, }, }, ], [ { jsonrpc: '2.0', id: 1301, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#status', fields: ['exists', 'innerText', 'opacity'] }, assertions: [ { field: 'exists', op: 'equals', value: true }, { field: 'innerText', op: 'contains', value: 'ready' }, { field: 'opacity', op: 'gte', value: 0.9 }, ], }, }), ], }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 1301))).toContain('status: completed'); }); it('returns failedAssertionIndex, expected, and actual when assert_query fails', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Assert Failure Page', currentUrl: 'https://example.com/assert-failure', query: { '#status': { exists: true, connected: true, innerText: 'loading', textContent: 'loading', display: 'block', visibility: 'visible', opacity: '0.42', rect: { x: 20, y: 30, width: 100, height: 40, top: 30, right: 120, bottom: 70, left: 20, }, }, }, }, ], [ { jsonrpc: '2.0', id: 1302, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#status', fields: ['innerText', 'opacity'] }, assertions: [ { field: 'innerText', op: 'contains', value: 'ready' }, { field: 'opacity', op: 'gte', value: 0.9 }, ], }, }), ], }, }, }, { jsonrpc: '2.0', id: 1303, method: 'tools/call', params: { name: 'browser_get_orchestration', arguments: {} }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 1303)); expect(text).toContain('status: failed'); expect(text).toContain('failedBlockIndex: 0'); expect(text).toContain('failedAssertionIndex: 0'); expect(text).toContain('field: innerText'); expect(text).toContain('expected: "ready"'); expect(text).toContain('actual: "loading"'); }); it('fails when numeric comparison is used on a non-number-like field or when op is unsupported', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Assert Type Error Page', currentUrl: 'https://example.com/assert-type-error', query: { '#status': { exists: true, connected: true, innerText: 'loading', textContent: 'loading', opacity: '0.42', display: 'block', visibility: 'visible', rect: { x: 20, y: 30, width: 100, height: 40, top: 30, right: 120, bottom: 70, left: 20, }, }, }, }, ], [ { jsonrpc: '2.0', id: 1304, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#status', fields: ['innerText'] }, assertions: [{ field: 'innerText', op: 'gte', value: 1 }], }, }), ], }, }, }, { jsonrpc: '2.0', id: 1305, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#status', fields: ['opacity'] }, assertions: [{ field: 'opacity', op: 'matches', value: '.*' }], }, }), ], }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 1304))).toContain('numeric comparison expects a number-like field: innerText'); expect(getResponseText(responses.find((message) => message.id === 1305))).toContain('unsupported assertion operator'); }); it('passes numeric gte and lt assertions', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Numeric Assertion Page', currentUrl: 'https://example.com/assert-numeric', query: { '#status': { exists: true, connected: true, opacity: '0.95', display: 'block', visibility: 'visible', rect: { x: 20, y: 30, width: 100, height: 40, top: 30, right: 120, bottom: 70, left: 20, }, }, }, }, ], [ { jsonrpc: '2.0', id: 1306, method: 'tools/call', params: { name: 'browser_start_orchestration', arguments: { blocks: [ createOrchestrationBlock({ type: 'assert_query', args: { query: { selector: '#status', fields: ['opacity'] }, assertions: [ { field: 'opacity', op: 'gte', value: 0.9 }, { field: 'opacity', op: 'lt', value: 1.0 }, ], }, }), ], }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 1306))).toContain('status: completed'); }); 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'); const receiptPath = join(tempDir, 'receipt.png'); writeFileSync(invoicePath, 'invoice'); writeFileSync(receiptPath, 'receipt'); const pages: MockPageState[] = [ { id: 'page-1', title: 'Uploads', currentUrl: 'https://example.com/uploads', dropzones: { '#dropzone': { accepted: true }, }, frames: [ { selector: '#upload-frame', dropzones: { '#frame-dropzone': { accepted: true }, }, }, ], shadowRoots: [ { hostSelector: 'upload-panel', dropzones: { '#shadow-dropzone': { accepted: true }, }, }, ], }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 901, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, }, }, { jsonrpc: '2.0', id: 902, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { selector: '#frame-dropzone', files: [invoicePath], frameSelector: '#upload-frame', }, }, }, { jsonrpc: '2.0', id: 903, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { selector: '#shadow-dropzone', files: [receiptPath], pierceShadow: true, }, }, }, ]); expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ 'dragenter', 'dragover', 'drop', ]); expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedFiles).toEqual([ { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, { name: 'receipt.png', size: 7, type: 'image/png' }, ]); expect( getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles ).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]); expect( getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles ).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]); }); it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); const okPath = join(tempDir, 'ok.txt'); const missingPath = join(tempDir, 'missing.txt'); writeFileSync(okPath, 'ok'); const responses = await runMcpRequests( [ { id: 'page-1', title: 'Uploads', currentUrl: 'https://example.com/uploads', dropzones: { '#reject-dropzone': { accepted: false }, }, }, ], [ { jsonrpc: '2.0', id: 904, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { selector: '#reject-dropzone', files: [missingPath] }, }, }, { jsonrpc: '2.0', id: 905, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, }, }, { jsonrpc: '2.0', id: 906, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { selector: '#missing-dropzone', files: [okPath] }, }, }, { jsonrpc: '2.0', id: 907, method: 'tools/call', params: { name: 'browser_drag_files', arguments: { selector: '#reject-dropzone', files: [okPath] }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); expect(getResponseText(responses.find((message) => message.id === 905))).toContain( 'pageIndex and pageId cannot be used together' ); expect(getResponseText(responses.find((message) => message.id === 906))).toContain( 'element not found for selector: #missing-dropzone' ); expect(getResponseText(responses.find((message) => message.id === 907))).toContain( 'drop target rejected files' ); }); it('drags an element to another element and to explicit coordinates', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Drag Page', currentUrl: 'https://example.com/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', }, }, }, { id: 'page-2', title: 'Second Drag Page', currentUrl: 'https://example.com/drag-2', query: { '#card-a': { exists: true, connected: true, rect: { x: 10, y: 20, width: 40, height: 20, top: 20, right: 50, bottom: 40, left: 10, }, display: 'block', visibility: 'visible', opacity: '1', }, '#lane-b': { exists: true, connected: true, rect: { x: 110, y: 80, width: 60, height: 30, top: 80, right: 170, bottom: 110, left: 110, }, display: 'block', visibility: 'visible', opacity: '1', }, }, }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 908, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { selector: '#card-a', targetSelector: '#lane-b', steps: 3 }, }, }, { jsonrpc: '2.0', id: 909, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { selector: '#card-a', targetX: 420, targetY: 180, steps: 2 }, }, }, { jsonrpc: '2.0', id: 916, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { pageId: 'page-2', selector: '#card-a', targetSelector: '#lane-b', steps: 1 }, }, }, ]); expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged'); expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b'); expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged'); expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420'); expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180'); expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1'); expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b'); expect(pages[0]!.drag?.recordedActions).toEqual([ { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, { type: 'mousePressed', x: 70, y: 70, button: 'left' }, { type: 'mouseMoved', x: 147, y: 80, button: 'left' }, { type: 'mouseMoved', x: 223, y: 90, button: 'left' }, { type: 'mouseMoved', x: 300, y: 100, button: 'left' }, { type: 'mouseReleased', x: 300, y: 100, button: 'left' }, { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, { type: 'mousePressed', x: 70, y: 70, button: 'left' }, { type: 'mouseMoved', x: 245, y: 125, button: 'left' }, { type: 'mouseMoved', x: 420, y: 180, button: 'left' }, { type: 'mouseReleased', x: 420, y: 180, button: 'left' }, ]); expect(pages[1]!.drag?.recordedActions).toEqual([ { type: 'mouseMoved', x: 30, y: 30, button: 'none' }, { type: 'mousePressed', x: 30, y: 30, button: 'left' }, { type: 'mouseMoved', x: 140, y: 95, button: 'left' }, { type: 'mouseReleased', x: 140, y: 95, button: 'left' }, ]); }); it('rejects invalid browser_drag_element target combinations, page conflicts, and missing elements', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Drag Failures', currentUrl: 'https://example.com/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', }, }, }, ], [ { jsonrpc: '2.0', id: 910, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, }, }, { jsonrpc: '2.0', id: 911, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { selector: '#missing-source', targetX: 400, targetY: 200 }, }, }, { jsonrpc: '2.0', id: 912, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { selector: '#card-a', targetSelector: '#missing-target' }, }, }, { jsonrpc: '2.0', id: 917, method: 'tools/call', params: { name: 'browser_drag_element', arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 910))).toContain( 'targetSelector and targetX/targetY cannot be used together' ); expect(getResponseText(responses.find((message) => message.id === 911))).toContain( 'source element not found' ); expect(getResponseText(responses.find((message) => message.id === 912))).toContain( 'target element not found' ); expect(getResponseText(responses.find((message) => message.id === 917))).toContain( 'pageIndex and pageId cannot be used together' ); }); it('runs minimal pointer sequences with browser_pointer_action', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Pointer Page', currentUrl: 'https://example.com/pointer', query: { '#handle': { exists: true, connected: true, rect: { x: 30, y: 50, width: 40, height: 20, top: 50, right: 70, bottom: 70, left: 30, }, display: 'block', visibility: 'visible', opacity: '1', }, }, }, { id: 'page-2', title: 'Second Pointer Page', currentUrl: 'https://example.com/pointer-2', query: { '#handle': { exists: true, connected: true, rect: { x: 100, y: 200, width: 20, height: 20, top: 200, right: 120, bottom: 220, left: 100, }, display: 'block', visibility: 'visible', opacity: '1', }, }, }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 913, method: 'tools/call', params: { name: 'browser_pointer_action', arguments: { actions: [ { type: 'move', selector: '#handle' }, { type: 'down' }, { type: 'move', x: 220, y: 160 }, { type: 'pause', durationMs: 5 }, { type: 'up' }, ], }, }, }, { jsonrpc: '2.0', id: 918, method: 'tools/call', params: { name: 'browser_pointer_action', arguments: { pageId: 'page-2', actions: [ { type: 'move', selector: '#handle' }, { type: 'down' }, { type: 'up' }, ], }, }, }, ]); expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed'); expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1'); expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed'); expect(pages[0]!.drag?.recordedActions).toEqual([ { type: 'mouseMoved', x: 50, y: 60, button: 'none' }, { type: 'mousePressed', x: 50, y: 60, button: 'left' }, { type: 'mouseMoved', x: 220, y: 160, button: 'left' }, { type: 'mouseReleased', x: 220, y: 160, button: 'left' }, ]); expect(pages[1]!.drag?.recordedActions).toEqual([ { type: 'mouseMoved', x: 110, y: 210, button: 'none' }, { type: 'mousePressed', x: 110, y: 210, button: 'left' }, { type: 'mouseReleased', x: 110, y: 210, button: 'left' }, ]); }); it('rejects invalid browser_pointer_action sequences and page conflicts', async () => { const pages: MockPageState[] = [ { id: 'page-1', title: 'Pointer Failures', currentUrl: 'https://example.com/pointer', query: { '#handle': { exists: true, connected: true, rect: { x: 30, y: 50, width: 40, height: 20, top: 50, right: 70, bottom: 70, left: 30, }, display: 'block', visibility: 'visible', opacity: '1', }, }, }, ]; const responses = await runMcpRequests(pages, [ { jsonrpc: '2.0', id: 914, method: 'tools/call', params: { name: 'browser_pointer_action', arguments: { actions: [{ type: 'up' }], }, }, }, { jsonrpc: '2.0', id: 915, method: 'tools/call', params: { name: 'browser_pointer_action', arguments: { actions: [{ type: 'move', selector: '#missing-handle' }], }, }, }, { jsonrpc: '2.0', id: 919, method: 'tools/call', params: { name: 'browser_pointer_action', arguments: { pageIndex: 0, pageId: 'page-1', actions: [{ type: 'move', x: 10, y: 10 }], }, }, }, ]); expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable'); expect(getResponseText(responses.find((message) => message.id === 919))).toContain( 'pageIndex and pageId cannot be used together' ); }); it('waits for a matching navigation event with browser_wait_for_event', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Event Page', currentUrl: 'https://example.com/', events: { navigations: [{ url: 'https://example.com/checkout' }], }, }, ], [ { jsonrpc: '2.0', id: 69, method: 'tools/call', params: { name: 'browser_wait_for_event', arguments: { timeoutMs: 1000, event: { kind: 'navigation', urlIncludes: '/checkout' }, }, }, }, ] ); expect(getResponseText(responses.find((message) => message.id === 69))).toContain( 'status: observed' ); }); it('ignores child-frame navigations when waiting for a page navigation event', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Event Page', currentUrl: 'https://example.com/', events: { navigations: [ { url: 'https://example.com/embedded-checkout', parentId: 'frame-1' }, { url: 'https://example.com/checkout' }, ], }, }, ], [ { jsonrpc: '2.0', id: 58, method: 'tools/call', params: { name: 'browser_wait_for_event', arguments: { timeoutMs: 1000, event: { kind: 'navigation', urlIncludes: '/checkout' }, }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 58)); expect(text).toContain('status: observed'); expect(text).toContain('"url":"https://example.com/checkout"'); expect(text).not.toContain('embedded-checkout'); }); it('captures element screenshots using the post-scroll visible clip and reports failures', async () => { const screenshotPlan: MockPageState['screenshot'] = { data: 'ZWxlbWVudC1zaG90', requireScrolledMeasurement: true, expectedClip: { x: 0, y: 12, width: 50, height: 28, scale: 1, }, }; const responses = await runMcpRequests( [ { id: 'page-1', title: 'Element Screenshot Page', currentUrl: 'https://example.com/', query: { '.edit-button': { exists: true, connected: true, innerText: 'Edit', textContent: 'Edit', rect: { x: -14, y: 12, width: 64, height: 28, top: 12, right: 50, bottom: 40, left: -14, }, display: 'block', visibility: 'visible', opacity: '1', }, '.zero-sized': { exists: true, connected: true, rect: { x: 10, y: 10, width: 0, height: 0, top: 10, right: 10, bottom: 10, left: 10, }, display: 'block', visibility: 'visible', opacity: '1', }, }, screenshot: screenshotPlan, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_take_element_screenshot', arguments: { selector: '.edit-button' }, }, }, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'browser_take_element_screenshot', arguments: { selector: '.missing-button' }, }, }, { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'browser_take_element_screenshot', arguments: { selector: '.zero-sized' }, }, }, ] ); const text = getResponseText(responses.find((message) => message.id === 2)); expect(text).toContain('selector: .edit-button'); expect(text).toContain('format: png'); expect(text).toContain('data: ZWxlbWVudC1zaG90'); expect(getResponseText(responses.find((message) => message.id === 3))).toContain( 'Browser MCP failed: element not found for selector: .missing-button' ); expect(getResponseText(responses.find((message) => message.id === 4))).toContain( 'Browser MCP failed: element has empty bounds for selector: .zero-sized' ); const emptyPayloadResponses = await runMcpRequests( [ { id: 'page-1', title: 'Element Screenshot Empty Payload Page', currentUrl: 'https://example.com/', query: { '.edit-button': { exists: true, connected: true, rect: { x: 220, y: 80, width: 64, height: 28, top: 80, right: 284, bottom: 108, left: 220, }, display: 'block', visibility: 'visible', opacity: '1', }, }, screenshot: { data: '' }, }, ], [ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_take_element_screenshot', arguments: { selector: '.edit-button' }, }, }, ] ); expect(getResponseText(emptyPayloadResponses.find((message) => message.id === 2))).toContain( 'Browser MCP failed: screenshot capture failed' ); const iframeScreenshotResponses = await runMcpRequests( [ { id: 'page-1', title: 'Iframe Screenshot Page', currentUrl: 'https://example.com/', frames: [ { selector: '#details-frame', query: { '#details-frame': { exists: true, connected: true, rect: { x: 40, y: 100, width: 300, height: 200, top: 100, right: 340, bottom: 300, left: 40, }, display: 'block', visibility: 'visible', opacity: '1', }, '.save-button': { exists: true, connected: true, rect: { x: 10, y: 20, width: 60, height: 24, top: 20, right: 70, bottom: 44, left: 10, }, display: 'block', visibility: 'visible', opacity: '1', }, }, }, ], screenshot: { data: 'aWZyYW1lLXNob3Q=', requireScrolledMeasurement: true, expectedClip: { x: 50, y: 120, width: 60, height: 24, scale: 1, }, }, }, ], [ { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'browser_take_element_screenshot', arguments: { selector: '.save-button', frameSelector: '#details-frame' }, }, }, ] ); expect( getResponseText(iframeScreenshotResponses.find((message) => message.id === 5)) ).toContain('data: aWZyYW1lLXNob3Q='); }); 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' ); }); });