import { describe, expect, it } from 'bun:test'; import { bundledServerPath, cpSync, getMockFrame, getMockShadowRoot, getResponseText, join, mkdtempSync, resolveNodeModulesPath, rmSync, runMcpRequests, tmpdir, writeFileSync } from './browser-mcp-test-harness'; describe('ccs-browser MCP server - navigation and query', () => { 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', }, Infinity: { unserializableValue: 'Infinity', }, 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: 'Infinity' }, }, }, { jsonrpc: '2.0', id: 55, 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( 'value: "Infinity"' ); expect(getResponseText(responses.find((message) => message.id === 55))).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('hides browser_eval when eval mode is disabled', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Eval Disabled Page', currentUrl: 'https://example.com/' }], [ { jsonrpc: '2.0', id: 56, method: 'tools/list', params: {}, }, { jsonrpc: '2.0', id: 57, method: 'tools/call', params: { name: 'browser_eval', arguments: { expression: '1 + 1' }, }, }, ], { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'disabled' } } ); const tools = (responses.find((message) => message.id === 56)?.result as { tools?: Array<{ name?: string }> }) ?.tools || []; expect(tools.some((tool) => tool.name === 'browser_eval')).toBe(false); expect((responses.find((message) => message.id === 57)?.error as { message?: string })?.message).toBe( 'Unknown tool: browser_eval' ); }); 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' ); }); });