From 50fa27f0a55b8ff8eeccbe5b19c4d04aa8724605 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 23:07:50 -0400 Subject: [PATCH] test(browser): split Browser MCP hook coverage by domain --- src/ccs.ts | 8 +- src/cliproxy/executor/env-resolver.ts | 6 +- .../env-resolver-codex-fallback.test.ts | 36 + .../browser-mcp-advanced-interactions.test.ts | 1288 +++ .../browser-mcp-downloads-and-files.test.ts | 413 + .../browser-mcp-navigation-and-query.test.ts | 1696 +++ ...er-mcp-orchestration-and-artifacts.test.ts | 1765 +++ .../browser-mcp-recording-and-replay.test.ts | 626 ++ ...browser-mcp-session-and-intercepts.test.ts | 1817 ++++ tests/unit/hooks/browser-mcp-test-harness.ts | 1861 ++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 9414 ----------------- .../settings-profile-browser-launch.test.ts | 35 + 12 files changed, 9545 insertions(+), 9420 deletions(-) create mode 100644 tests/unit/hooks/browser-mcp-advanced-interactions.test.ts create mode 100644 tests/unit/hooks/browser-mcp-downloads-and-files.test.ts create mode 100644 tests/unit/hooks/browser-mcp-navigation-and-query.test.ts create mode 100644 tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts create mode 100644 tests/unit/hooks/browser-mcp-recording-and-replay.test.ts create mode 100644 tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts create mode 100644 tests/unit/hooks/browser-mcp-test-harness.ts delete mode 100644 tests/unit/hooks/ccs-browser-mcp-server.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index 0a5ca3df..23389316 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors'; import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions -import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor'; +import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; @@ -1367,8 +1367,9 @@ async function main(): Promise { // env free of ANTHROPIC routing/auth while preserving non-routing profile // env so nested Team/subagent sessions can still inherit model intent and // other profile-scoped runtime flags. + const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { - ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...stripAnthropicRoutingEnv(settingsRuntimeEnv), ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, @@ -1379,8 +1380,7 @@ async function main(): Promise { // Non-Claude targets still need effective credentials injected directly. const envVars: NodeJS.ProcessEnv = { - ...globalEnv, - ...settingsEnv, + ...settingsRuntimeEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 07bb96a4..b5872947 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -390,8 +390,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record ) as Record; - const effectiveEnvVarsFiltered = Object.fromEntries( - Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + const effectiveEnvVarsFiltered = stripBrowserEnv( + Object.fromEntries( + Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + ) as Record ) as Record; const mergedEnv = { diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 39ee4e6d..2d8dee54 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -215,6 +215,42 @@ describe('resolveCliproxyImageAnalysisEnv', () => { expect(result.warning).toContain('native Read'); }); + it('strips browser env injected through settings before building Claude runtime env', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-env-strip-')); + tempDirs.push(tempDir); + + const settingsPath = path.join(tempDir, 'browser-env.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'gemini-2.5-pro', + CCS_BROWSER_USER_DATA_DIR: '/tmp/embedded-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/embedded-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env', + }, + }, + null, + 2 + ) + '\n' + ); + + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + verbose: false, + }); + + expect(env.CCS_BROWSER_USER_DATA_DIR).toBeUndefined(); + expect(env.CCS_BROWSER_PROFILE_DIR).toBeUndefined(); + expect(env.CCS_BROWSER_DEVTOOLS_WS_URL).toBeUndefined(); + }); + it('keeps cliproxy image analysis active when the execution target is reachable', async () => { const result = await resolveCliproxyImageAnalysisEnv( { diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts new file mode 100644 index 00000000..bb6d42a4 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -0,0 +1,1288 @@ +import { describe, expect, it } from 'bun:test'; +import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - advanced interactions', () => { + 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 }, + '#cancel-dropzone': { acceptedByCancel: 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, + }, + }, + }, + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#cancel-dropzone', + files: [invoicePath], + }, + }, + }, + ]); + + 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' }]); + expect(getResponseText(responses.find((message) => message.id === 904))).toContain( + 'status: files-dropped' + ); + expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + ]); + }); + + 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 }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 920, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'right' }, + ], + }, + }, + }, + ]); + + 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' + ); + expect(getResponseText(responses.find((message) => message.id === 920))).toContain( + 'pointer state error' + ); + }); + + it('rejects mutating browser tools when the selected page becomes stale', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-')); + const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs'); + writeFileSync( + patchedServerPath, + readFileSync(bundledServerPath, 'utf8').replace( + "let selectedPageId = '';", + "let selectedPageId = 'page-2';" + ) + ); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Primary Page', + currentUrl: 'https://example.com/primary', + 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', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 923, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#handle' }], + }, + }, + }, + ], + { serverPath: patchedServerPath } + ); + + expect(getResponseText(responses.find((message) => message.id === 923))).toContain( + 'Selected page is no longer available; specify pageIndex or pageId explicitly.' + ); + }); + + 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('filters download events by the selected page frame when pageIndex is provided', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'First Page', + currentUrl: 'https://example.com/one', + events: { + downloads: [ + { + url: 'https://example.com/first-report.csv', + suggestedFilename: 'first-report.csv', + frameId: 'frame-page-1', + }, + ], + }, + }, + { + id: 'page-2', + title: 'Second Page', + currentUrl: 'https://example.com/two', + events: { + downloads: [ + { + url: 'https://example.com/second-report.csv', + suggestedFilename: 'second-report.csv', + frameId: 'frame-page-2', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 59, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 1, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'second-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 59)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"second-report.csv"'); + expect(text).not.toContain('first-report.csv'); + }); + + it('matches download events from child frames within the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Framed Page', + currentUrl: 'https://example.com/frame-host', + frameTree: { + frame: { id: 'frame-page-1' }, + childFrames: [{ frame: { id: 'frame-page-1-child' } }], + }, + events: { + downloads: [ + { + url: 'https://example.com/embedded-report.csv', + suggestedFilename: 'embedded-report.csv', + frameId: 'frame-page-1-child', + }, + ], + }, + }, + { + id: 'page-2', + title: 'Other Page', + currentUrl: 'https://example.com/other', + events: { + downloads: [ + { + url: 'https://example.com/other-report.csv', + suggestedFilename: 'other-report.csv', + frameId: 'frame-page-2', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 60, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 0, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 60)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"embedded-report.csv"'); + expect(text).not.toContain('other-report.csv'); + }); + + it('refreshes frame ids when a download comes from a newly attached child frame', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Dynamic Frame Page', + currentUrl: 'https://example.com/dynamic', + frameTreeSequence: [ + { frame: { id: 'frame-page-1' } }, + { + frame: { id: 'frame-page-1' }, + childFrames: [{ frame: { id: 'frame-page-1-late-child' } }], + }, + ], + events: { + downloads: [ + { + url: 'https://example.com/late-report.csv', + suggestedFilename: 'late-report.csv', + frameId: 'frame-page-1-late-child', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 61, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 0, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'late-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 61)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"late-report.csv"'); + }); + + 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' + ); + }); +}); diff --git a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts new file mode 100644 index 00000000..dae29085 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - downloads and file inputs', () => { + 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' + ); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts new file mode 100644 index 00000000..bbd09250 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts @@ -0,0 +1,1696 @@ +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' + ); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts b/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts new file mode 100644 index 00000000..4ba36192 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts @@ -0,0 +1,1765 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, createReplayStep, createOrchestrationBlock, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - orchestration and artifacts', () => { + 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('cancels orchestration before later blocks run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Cancel Page', + currentUrl: 'https://example.com/orchestration-cancel', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + rect: { + x: 80, + y: 30, + width: 80, + height: 20, + top: 30, + right: 160, + bottom: 50, + left: 80, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1202_1, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202_2, + method: 'tools/call', + params: { name: 'browser_cancel_orchestration', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1202_3, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1202_3)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedBlocks: 2'); + }); + + 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('runs a single-layer sequence with two successful blocks', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Page', + currentUrl: 'https://example.com/sequence', + 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', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1401, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1'); + }); + + it('reports failedSequenceStepIndex when a sequence step fails', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Failure Page', + currentUrl: 'https://example.com/sequence-failure', + 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', + }, + ], + ], + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1402, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1403, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1403)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedSequenceStepIndex: 1'); + }); + + it('rejects empty sequence steps and nested sequence blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }], + [ + { + jsonrpc: '2.0', + id: 1404, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1405, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks'); + }); + + it('runs a top-level block followed by a sequence in order', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Mixed Sequence Page', + currentUrl: 'https://example.com/mixed-sequence', + 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: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1406, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2'); + }); + + 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('continues to the next top-level block when continueOnError is true', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Policy Page', + currentUrl: 'https://example.com/policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1501, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1502, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1502)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].blockIndex: 0'); + }); + + it('continues inside sequence when a step has continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Policy Page', + currentUrl: 'https://example.com/sequence-policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1503, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1504, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1504)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 1'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('still stops immediately when continueOnError is not enabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Strict Stop Page', currentUrl: 'https://example.com/strict-stop' }], + [ + { + jsonrpc: '2.0', + id: 1505, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1505)); + expect(text).toContain('status: failed'); + expect(text).toContain('completedBlocks: 0'); + }); + + it('continues to the next top-level block when a sequence block itself allows continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Top-Level Policy Page', + currentUrl: 'https://example.com/sequence-top-policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1506, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + continueOnError: true, + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1507, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1507)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('exports recording and replay artifacts and lists them', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + timestamp: 1710000000000, + }, + ], + }, + 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: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { + jsonrpc: '2.0', + id: 1603, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1604, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1605, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1606, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported'); + const listText = getResponseText(responses.find((message) => message.id === 1606)); + expect(listText).toContain('name: rec-smoke'); + expect(listText).toContain('name: replay-smoke'); + }); + + it('imports and deletes orchestration artifacts', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + click: { '#submit': {} }, + wait: { + selectorSnapshots: { + '#submit': [ + [ + { + 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: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1607, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1608, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1609, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1610, + method: 'tools/call', + params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1611, + method: 'tools/call', + params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported'); + expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted'); + }); + + it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-')); + const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json'); + writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a')); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Failure Page', + currentUrl: 'https://example.com/artifact-failure', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, + { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, + { jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } }, + { jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } }, + { jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); + expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); + expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); + expect(getResponseText(responses.find((message) => message.id === 1618))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1619))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1620))).toContain( + 'artifact file exceeds maximum size' + ); + }); + + it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1701, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 1 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); + }); + + it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1702, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'open_page_then_run', + args: { + open: { url: 'https://example.com/docs' }, + run: { + type: 'wait_for_then_press_key', + args: { + wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, + pressKey: { key: 'Enter' }, + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); + }); + + it('runs close_page_then_continue and then continues with the remaining blocks', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, + { + jsonrpc: '2.0', + id: 1704, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'close_page_then_continue', + args: { close: { pageId: 'page-2' } }, + }), + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + continueOnError: true, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); + }); + + it('rejects missing run payloads and nested cross-page blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1705, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1706, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: createOrchestrationBlock({ + type: 'open_page_then_run', + args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, + }), + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); + expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); + }); + + it('fails when select_page_then_run targets a missing page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1707, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageId: 'page-9' }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1708, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1708)); + expect(text).toContain('status: failed'); + expect(text).toContain('page not found: page-9'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts b/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts new file mode 100644 index 00000000..782108d4 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts @@ -0,0 +1,626 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, createReplayStep } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - recording and replay', () => { + 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('cleans up recording state when stop finalization fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Stop Recording Page', + currentUrl: 'https://example.com/broken-stop-recording', + recording: { + finalizeError: 'recording finalize failed', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1019_1, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_2, + method: 'tools/call', + params: { name: 'browser_stop_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_3, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain( + 'recording finalize failed' + ); + expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain( + 'status: recording' + ); + }); + + 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('cancels a replay before later steps run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Cancel Page', + currentUrl: 'https://example.com/replay-cancel', + click: { + '#submit': {}, + }, + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#submit': { + exists: true, + connected: true, + rect: { + x: 120, + y: 30, + width: 100, + height: 40, + top: 30, + right: 220, + bottom: 70, + left: 120, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1116, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + args: {}, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1117, + method: 'tools/call', + params: { name: 'browser_cancel_replay', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1118, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1116))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1118)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedSteps: 2'); + }); + + 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'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts new file mode 100644 index 00000000..554a25c7 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -0,0 +1,1817 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - session and interception', () => { + 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_export_artifact', + 'browser_import_artifact', + 'browser_list_artifacts', + 'browser_delete_artifact', + '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 exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); + expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' }); + expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact'); + expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' }); + + const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts'); + expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' }); + + const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact'); + expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + 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'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-test-harness.ts b/tests/unit/hooks/browser-mcp-test-harness.ts new file mode 100644 index 00000000..fd78c81f --- /dev/null +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -0,0 +1,1861 @@ +// Shared harness extracted from the Browser MCP hook suite. +// Keep helpers here and split domain assertions into smaller *.test.ts files. + +import { spawn } from 'child_process'; +import { cpSync, existsSync, mkdtempSync, readFileSync, 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; + frameId?: 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; + acceptedByCancel?: 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; + finalizeError?: 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; + unserializableValue?: string; + } +>; + +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 MockFrameTree = { + frame: { id: string }; + childFrames?: MockFrameTree[]; +}; + +type MockPageState = { + id: string; + title: string; + currentUrl: string; + fileInputs?: Record; + browser?: MockBrowserState; + frameTree?: MockFrameTree; + frameTreeSequence?: MockFrameTree[]; + 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({}); + return; + } + }); + + 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: download.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.getFrameTree') { + const nextFrameTree = page.frameTreeSequence?.shift(); + reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); + 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); + if (plan.finalizeError) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: plan.finalizeError } }, + }) + ); + return; + } + 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.acceptedByCancel === true ? true : 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; + } + if (typeof evalPlan.unserializableValue === 'string') { + socket.send( + JSON.stringify({ + id: message.id, + result: { + result: { + type: 'number', + unserializableValue: evalPlan.unserializableValue, + }, + }, + }) + ); + 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(); + } +} + + +export { + bundledServerPath, + runMcpRequests, + getResponseText, + createReplayStep, + createOrchestrationBlock, + getMockDropzoneState, + getMockFrame, + getMockShadowRoot, + getMockRecordingPlan, + resolveNodeModulesPath, + cpSync, + rmSync, + mkdtempSync, + readFileSync, + writeFileSync, + tmpdir, + join, +}; +export type { MockPageState }; diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts deleted file mode 100644 index 99493228..00000000 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ /dev/null @@ -1,9414 +0,0 @@ -import { afterEach, describe, expect, it } from 'bun:test'; -import { spawn } from 'child_process'; -import { cpSync, existsSync, mkdtempSync, readFileSync, 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; - frameId?: 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; - acceptedByCancel?: 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; - finalizeError?: 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; - unserializableValue?: string; - } ->; - -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 MockFrameTree = { - frame: { id: string }; - childFrames?: MockFrameTree[]; -}; - -type MockPageState = { - id: string; - title: string; - currentUrl: string; - fileInputs?: Record; - browser?: MockBrowserState; - frameTree?: MockFrameTree; - frameTreeSequence?: MockFrameTree[]; - 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({}); - return; - } - }); - - 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: download.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.getFrameTree') { - const nextFrameTree = page.frameTreeSequence?.shift(); - reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); - 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); - if (plan.finalizeError) { - socket.send( - JSON.stringify({ - id: message.id, - result: { exceptionDetails: { text: plan.finalizeError } }, - }) - ); - return; - } - 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.acceptedByCancel === true ? true : 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; - } - if (typeof evalPlan.unserializableValue === 'string') { - socket.send( - JSON.stringify({ - id: message.id, - result: { - result: { - type: 'number', - unserializableValue: evalPlan.unserializableValue, - }, - }, - }) - ); - 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_export_artifact', - 'browser_import_artifact', - 'browser_list_artifacts', - 'browser_delete_artifact', - '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 exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); - expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' }); - expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); - - const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact'); - expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' }); - - const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts'); - expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' }); - - const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact'); - expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); - - 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', - }, - 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' - ); - }); - - 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('cleans up recording state when stop finalization fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Broken Stop Recording Page', - currentUrl: 'https://example.com/broken-stop-recording', - recording: { - finalizeError: 'recording finalize failed', - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1019_1, - method: 'tools/call', - params: { name: 'browser_start_recording', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1019_2, - method: 'tools/call', - params: { name: 'browser_stop_recording', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1019_3, - method: 'tools/call', - params: { name: 'browser_start_recording', arguments: {} }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain( - 'recording finalize failed' - ); - expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain( - 'status: recording' - ); - }); - - 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('cancels a replay before later steps run', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Replay Cancel Page', - currentUrl: 'https://example.com/replay-cancel', - click: { - '#submit': {}, - }, - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 40, - height: 40, - top: 30, - right: 60, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#submit': { - exists: true, - connected: true, - rect: { - x: 120, - y: 30, - width: 100, - height: 40, - top: 30, - right: 220, - bottom: 70, - left: 120, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1116, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [ - createReplayStep({ - type: 'pointer_action', - pageId: 'page-1', - args: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'pause', durationMs: 400 }, - ], - }, - }), - createReplayStep({ - type: 'click', - pageId: 'page-1', - selector: '#submit', - args: {}, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1117, - method: 'tools/call', - params: { name: 'browser_cancel_replay', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1118, - method: 'tools/call', - params: { name: 'browser_get_replay', arguments: {} }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1116))).toContain( - 'status: running' - ); - const text = getResponseText(responses.find((message) => message.id === 1118)); - expect(text).toContain('status: canceled'); - expect(text).not.toContain('completedSteps: 2'); - }); - - 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('cancels orchestration before later blocks run', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Orchestration Cancel Page', - currentUrl: 'https://example.com/orchestration-cancel', - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 40, - height: 40, - top: 30, - right: 60, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#status': { - exists: true, - connected: true, - innerText: 'ready', - textContent: 'ready', - rect: { - x: 80, - y: 30, - width: 80, - height: 20, - top: 30, - right: 160, - bottom: 50, - left: 80, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1202_1, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'run_replay_sequence', - args: { - steps: [ - createReplayStep({ - type: 'pointer_action', - pageId: 'page-1', - args: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'pause', durationMs: 400 }, - ], - }, - }), - ], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1202_2, - method: 'tools/call', - params: { name: 'browser_cancel_orchestration', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1202_3, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain( - 'status: running' - ); - const text = getResponseText(responses.find((message) => message.id === 1202_3)); - expect(text).toContain('status: canceled'); - expect(text).not.toContain('completedBlocks: 2'); - }); - - 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('runs a single-layer sequence with two successful blocks', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Page', - currentUrl: 'https://example.com/sequence', - 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', - }, - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1401, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - click: { selector: '#menu' }, - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1'); - }); - - it('reports failedSequenceStepIndex when a sequence step fails', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Failure Page', - currentUrl: 'https://example.com/sequence-failure', - 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', - }, - ], - ], - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1402, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - click: { selector: '#menu' }, - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - ], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1403, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1403)); - expect(text).toContain('status: failed'); - expect(text).toContain('failedBlockIndex: 0'); - expect(text).toContain('failedSequenceStepIndex: 1'); - }); - - it('rejects empty sequence steps and nested sequence blocks', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }], - [ - { - jsonrpc: '2.0', - id: 1404, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1405, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array'); - expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks'); - }); - - it('runs a top-level block followed by a sequence in order', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Mixed Sequence Page', - currentUrl: 'https://example.com/mixed-sequence', - 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: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1406, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - click: { selector: '#menu' }, - }, - }), - ], - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2'); - }); - - 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('continues to the next top-level block when continueOnError is true', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Policy Page', - currentUrl: 'https://example.com/policy', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1501, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - continueOnError: true, - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1502, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1502)); - expect(text).toContain('status: completed_with_failures'); - expect(text).toContain('completedBlocks: 2'); - expect(text).toContain('failedCount: 1'); - expect(text).toContain('failure[0].blockIndex: 0'); - }); - - it('continues inside sequence when a step has continueOnError', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Policy Page', - currentUrl: 'https://example.com/sequence-policy', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1503, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'assert_query', - continueOnError: true, - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1504, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1504)); - expect(text).toContain('status: completed_with_failures'); - expect(text).toContain('completedBlocks: 1'); - expect(text).toContain('failedCount: 1'); - expect(text).toContain('failure[0].sequenceStepIndex: 0'); - }); - - it('still stops immediately when continueOnError is not enabled', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Strict Stop Page', currentUrl: 'https://example.com/strict-stop' }], - [ - { - jsonrpc: '2.0', - id: 1505, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - continueOnError: true, - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1505)); - expect(text).toContain('status: failed'); - expect(text).toContain('completedBlocks: 0'); - }); - - it('continues to the next top-level block when a sequence block itself allows continueOnError', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Top-Level Policy Page', - currentUrl: 'https://example.com/sequence-top-policy', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1506, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - continueOnError: true, - args: { - steps: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - ], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1507, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1507)); - expect(text).toContain('status: completed_with_failures'); - expect(text).toContain('completedBlocks: 2'); - expect(text).toContain('failedCount: 1'); - expect(text).toContain('failure[0].sequenceStepIndex: 0'); - }); - - it('exports recording and replay artifacts and lists them', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Artifact Page', - currentUrl: 'https://example.com/artifact', - recording: { - events: [ - { - kind: 'click', - selector: '#submit', - timestamp: 1710000000000, - }, - ], - }, - 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: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { - jsonrpc: '2.0', - id: 1603, - method: 'tools/call', - params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1604, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1605, - method: 'tools/call', - params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1606, - method: 'tools/call', - params: { name: 'browser_list_artifacts', arguments: {} }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported'); - expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported'); - const listText = getResponseText(responses.find((message) => message.id === 1606)); - expect(listText).toContain('name: rec-smoke'); - expect(listText).toContain('name: replay-smoke'); - }); - - it('imports and deletes orchestration artifacts', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Artifact Page', - currentUrl: 'https://example.com/artifact', - click: { '#submit': {} }, - wait: { - selectorSnapshots: { - '#submit': [ - [ - { - 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: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1607, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1608, - method: 'tools/call', - params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1609, - method: 'tools/call', - params: { name: 'browser_list_artifacts', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1610, - method: 'tools/call', - params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1611, - method: 'tools/call', - params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported'); - expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported'); - expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted'); - }); - - it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-')); - const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json'); - writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a')); - - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Artifact Failure Page', - currentUrl: 'https://example.com/artifact-failure', - recording: { - events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, - { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, - { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, - { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, - { jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } }, - { jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } }, - { jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); - expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); - expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); - expect(getResponseText(responses.find((message) => message.id === 1618))).toContain( - 'artifact name must start with a letter or number' - ); - expect(getResponseText(responses.find((message) => message.id === 1619))).toContain( - 'artifact name must start with a letter or number' - ); - expect(getResponseText(responses.find((message) => message.id === 1620))).toContain( - 'artifact file exceeds maximum size' - ); - }); - - it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1701, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageIndex: 1 }, - run: { - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }, - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); - }); - - it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1702, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'open_page_then_run', - args: { - open: { url: 'https://example.com/docs' }, - run: { - type: 'wait_for_then_press_key', - args: { - wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, - pressKey: { key: 'Enter' }, - }, - }, - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); - }); - - it('runs close_page_then_continue and then continues with the remaining blocks', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, - { - jsonrpc: '2.0', - id: 1704, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'close_page_then_continue', - args: { close: { pageId: 'page-2' } }, - }), - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageIndex: 0 }, - run: { - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }, - }, - continueOnError: true, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); - }); - - it('rejects missing run payloads and nested cross-page blocks', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1705, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1706, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageIndex: 0 }, - run: createOrchestrationBlock({ - type: 'open_page_then_run', - args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, - }), - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); - expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); - }); - - it('fails when select_page_then_run targets a missing page', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1707, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageId: 'page-9' }, - run: { - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }, - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1708, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1708)); - expect(text).toContain('status: failed'); - expect(text).toContain('page not found: page-9'); - }); - - 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 }, - '#cancel-dropzone': { acceptedByCancel: 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, - }, - }, - }, - { - jsonrpc: '2.0', - id: 904, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#cancel-dropzone', - files: [invoicePath], - }, - }, - }, - ]); - - 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' }]); - expect(getResponseText(responses.find((message) => message.id === 904))).toContain( - 'status: files-dropped' - ); - expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([ - { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, - ]); - }); - - 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 }], - }, - }, - }, - { - jsonrpc: '2.0', - id: 920, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'down', button: 'left' }, - { type: 'up', button: 'right' }, - ], - }, - }, - }, - ]); - - 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' - ); - expect(getResponseText(responses.find((message) => message.id === 920))).toContain( - 'pointer state error' - ); - }); - - it('rejects mutating browser tools when the selected page becomes stale', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-')); - const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs'); - writeFileSync( - patchedServerPath, - readFileSync(bundledServerPath, 'utf8').replace( - "let selectedPageId = '';", - "let selectedPageId = 'page-2';" - ) - ); - - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Primary Page', - currentUrl: 'https://example.com/primary', - 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', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 923, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [{ type: 'move', selector: '#handle' }], - }, - }, - }, - ], - { serverPath: patchedServerPath } - ); - - expect(getResponseText(responses.find((message) => message.id === 923))).toContain( - 'Selected page is no longer available; specify pageIndex or pageId explicitly.' - ); - }); - - 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('filters download events by the selected page frame when pageIndex is provided', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'First Page', - currentUrl: 'https://example.com/one', - events: { - downloads: [ - { - url: 'https://example.com/first-report.csv', - suggestedFilename: 'first-report.csv', - frameId: 'frame-page-1', - }, - ], - }, - }, - { - id: 'page-2', - title: 'Second Page', - currentUrl: 'https://example.com/two', - events: { - downloads: [ - { - url: 'https://example.com/second-report.csv', - suggestedFilename: 'second-report.csv', - frameId: 'frame-page-2', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 59, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - pageIndex: 1, - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'second-report' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 59)); - expect(text).toContain('status: observed'); - expect(text).toContain('"suggestedFilename":"second-report.csv"'); - expect(text).not.toContain('first-report.csv'); - }); - - it('matches download events from child frames within the selected page', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Framed Page', - currentUrl: 'https://example.com/frame-host', - frameTree: { - frame: { id: 'frame-page-1' }, - childFrames: [{ frame: { id: 'frame-page-1-child' } }], - }, - events: { - downloads: [ - { - url: 'https://example.com/embedded-report.csv', - suggestedFilename: 'embedded-report.csv', - frameId: 'frame-page-1-child', - }, - ], - }, - }, - { - id: 'page-2', - title: 'Other Page', - currentUrl: 'https://example.com/other', - events: { - downloads: [ - { - url: 'https://example.com/other-report.csv', - suggestedFilename: 'other-report.csv', - frameId: 'frame-page-2', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 60, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - pageIndex: 0, - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 60)); - expect(text).toContain('status: observed'); - expect(text).toContain('"suggestedFilename":"embedded-report.csv"'); - expect(text).not.toContain('other-report.csv'); - }); - - it('refreshes frame ids when a download comes from a newly attached child frame', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Dynamic Frame Page', - currentUrl: 'https://example.com/dynamic', - frameTreeSequence: [ - { frame: { id: 'frame-page-1' } }, - { - frame: { id: 'frame-page-1' }, - childFrames: [{ frame: { id: 'frame-page-1-late-child' } }], - }, - ], - events: { - downloads: [ - { - url: 'https://example.com/late-report.csv', - suggestedFilename: 'late-report.csv', - frameId: 'frame-page-1-late-child', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 61, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - pageIndex: 0, - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'late-report' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 61)); - expect(text).toContain('status: observed'); - expect(text).toContain('"suggestedFilename":"late-report.csv"'); - }); - - 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' - ); - }); -}); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 70b51b02..e89e8855 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -348,6 +348,41 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).not.toContain('stale-settings-env'); }); + it('scrubs CCS_BROWSER_* values embedded in settings-profile env when browser is off', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_MODEL: 'glm-5', + CCS_BROWSER_USER_DATA_DIR: '/tmp/settings-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/settings-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env', + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/settings-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/settings-browser-legacy'); + expect(launchedEnv).not.toContain('settings-env'); + }); + it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => { if (process.platform === 'win32') return;