diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 70a0b654..1cc92e38 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -15,9 +15,12 @@ function loadWebSocketImplementation() { } try { - const { WebSocket } = require('ws'); - if (typeof WebSocket === 'function') { - return WebSocket; + const wsModule = require('ws'); + if (typeof wsModule === 'function') { + return wsModule; + } + if (typeof wsModule?.WebSocket === 'function') { + return wsModule.WebSocket; } } catch { // Surface a dedicated error below if no implementation is available. @@ -29,6 +32,9 @@ function loadWebSocketImplementation() { } const WebSocket = loadWebSocketImplementation(); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const PROTOCOL_VERSION = '2024-11-05'; const SERVER_NAME = 'ccs-browser'; @@ -40,7 +46,43 @@ const TOOL_DOM_SNAPSHOT = 'browser_get_dom_snapshot'; const TOOL_NAVIGATE = 'browser_navigate'; const TOOL_CLICK = 'browser_click'; const TOOL_TYPE = 'browser_type'; +const TOOL_PRESS_KEY = 'browser_press_key'; +const TOOL_SCROLL = 'browser_scroll'; +const TOOL_SELECT_PAGE = 'browser_select_page'; +const TOOL_OPEN_PAGE = 'browser_open_page'; +const TOOL_CLOSE_PAGE = 'browser_close_page'; +const TOOL_ADD_INTERCEPT_RULE = 'browser_add_intercept_rule'; +const TOOL_REMOVE_INTERCEPT_RULE = 'browser_remove_intercept_rule'; +const TOOL_LIST_INTERCEPT_RULES = 'browser_list_intercept_rules'; +const TOOL_LIST_REQUESTS = 'browser_list_requests'; +const TOOL_SET_DOWNLOAD_BEHAVIOR = 'browser_set_download_behavior'; +const TOOL_LIST_DOWNLOADS = 'browser_list_downloads'; +const TOOL_CANCEL_DOWNLOAD = 'browser_cancel_download'; +const TOOL_SET_FILE_INPUT = 'browser_set_file_input'; +const TOOL_DRAG_FILES = 'browser_drag_files'; +const TOOL_DRAG_ELEMENT = 'browser_drag_element'; +const TOOL_POINTER_ACTION = 'browser_pointer_action'; +const TOOL_START_RECORDING = 'browser_start_recording'; +const TOOL_STOP_RECORDING = 'browser_stop_recording'; +const TOOL_GET_RECORDING = 'browser_get_recording'; +const TOOL_CLEAR_RECORDING = 'browser_clear_recording'; +const TOOL_START_REPLAY = 'browser_start_replay'; +const TOOL_GET_REPLAY = 'browser_get_replay'; +const TOOL_CANCEL_REPLAY = 'browser_cancel_replay'; +const TOOL_START_ORCHESTRATION = 'browser_start_orchestration'; +const TOOL_GET_ORCHESTRATION = 'browser_get_orchestration'; +const TOOL_CANCEL_ORCHESTRATION = 'browser_cancel_orchestration'; +const TOOL_EXPORT_ARTIFACT = 'browser_export_artifact'; +const TOOL_IMPORT_ARTIFACT = 'browser_import_artifact'; +const TOOL_LIST_ARTIFACTS = 'browser_list_artifacts'; +const TOOL_DELETE_ARTIFACT = 'browser_delete_artifact'; const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; +const TOOL_WAIT_FOR = 'browser_wait_for'; +const TOOL_EVAL = 'browser_eval'; +const TOOL_HOVER = 'browser_hover'; +const TOOL_QUERY = 'browser_query'; +const TOOL_TAKE_ELEMENT_SCREENSHOT = 'browser_take_element_screenshot'; +const TOOL_WAIT_FOR_EVENT = 'browser_wait_for_event'; const TOOL_NAMES = [ TOOL_SESSION_INFO, TOOL_URL_TITLE, @@ -49,13 +91,95 @@ const TOOL_NAMES = [ TOOL_NAVIGATE, TOOL_CLICK, TOOL_TYPE, + TOOL_PRESS_KEY, + TOOL_SCROLL, + TOOL_SELECT_PAGE, + TOOL_OPEN_PAGE, + TOOL_CLOSE_PAGE, + TOOL_ADD_INTERCEPT_RULE, + TOOL_REMOVE_INTERCEPT_RULE, + TOOL_LIST_INTERCEPT_RULES, + TOOL_LIST_REQUESTS, + TOOL_SET_DOWNLOAD_BEHAVIOR, + TOOL_LIST_DOWNLOADS, + TOOL_CANCEL_DOWNLOAD, + TOOL_SET_FILE_INPUT, + TOOL_DRAG_FILES, + TOOL_DRAG_ELEMENT, + TOOL_POINTER_ACTION, + TOOL_START_RECORDING, + TOOL_STOP_RECORDING, + TOOL_GET_RECORDING, + TOOL_CLEAR_RECORDING, + TOOL_START_REPLAY, + TOOL_GET_REPLAY, + TOOL_CANCEL_REPLAY, + TOOL_START_ORCHESTRATION, + TOOL_GET_ORCHESTRATION, + TOOL_CANCEL_ORCHESTRATION, + TOOL_EXPORT_ARTIFACT, + TOOL_IMPORT_ARTIFACT, + TOOL_LIST_ARTIFACTS, + TOOL_DELETE_ARTIFACT, TOOL_TAKE_SCREENSHOT, + TOOL_WAIT_FOR, + TOOL_EVAL, + TOOL_HOVER, + TOOL_QUERY, + TOOL_TAKE_ELEMENT_SCREENSHOT, + TOOL_WAIT_FOR_EVENT, ]; +const SUPPORTED_QUERY_FIELDS = [ + 'exists', + 'count', + 'innerText', + 'textContent', + 'boundingClientRect', + 'display', + 'visibility', + 'opacity', + 'href', + 'onclick', +]; +const DEFAULT_QUERY_FIELDS = [...SUPPORTED_QUERY_FIELDS]; +const SUPPORTED_QUERY_FIELD_SET = new Set(SUPPORTED_QUERY_FIELDS); const CDP_TIMEOUT_MS = 5000; const NAVIGATION_POLL_INTERVAL_MS = 100; +const DEFAULT_WAIT_TIMEOUT_MS = 2000; +const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; +const DEFAULT_DRAG_STEPS = 5; +const MAX_POINTER_ACTIONS = 25; +const SESSION_START_SETTLE_WINDOW_MS = 250; +const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024; +const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED'; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; +let selectedPageId = ''; +let messageQueue = Promise.resolve(); +let nextInterceptRuleCounter = 1; +let nextDownloadCounter = 1; +let nextRecordingCounter = 1; +let activeRecordingSession = null; +let latestRecordingSession = null; +let nextReplayCounter = 1; +let activeReplaySession = null; +let activeReplayTask = null; +let latestReplaySession = null; +let nextOrchestrationCounter = 1; +let activeOrchestrationSession = null; +let activeOrchestrationTask = null; +let latestOrchestrationSession = null; +const interceptRules = []; +const recentRequests = []; +const recentDownloads = []; +const interceptSessionsByPageId = new Map(); +let browserDownloadSession = null; +let sessionDownloadDir = ''; +const MAX_RECENT_REQUESTS = 100; +const MAX_RECENT_DOWNLOADS = 100; +const FETCH_FAIL_ERROR_REASON = 'Failed'; function addSocketListener(socket, eventName, handler) { if (typeof socket.addEventListener === 'function') { @@ -133,6 +257,21 @@ function writeError(id, code, message) { writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); } +function getAvailableTools() { + const tools = getTools(); + if (getBrowserEvalMode() === 'disabled') { + return tools.filter((tool) => tool.name !== TOOL_EVAL); + } + return tools; +} + +function getAvailableToolNames() { + if (getBrowserEvalMode() === 'disabled') { + return TOOL_NAMES.filter((toolName) => toolName !== TOOL_EVAL); + } + return [...TOOL_NAMES]; +} + function getTools() { if (!shouldExposeTools()) { return []; @@ -221,7 +360,7 @@ function getTools() { { name: TOOL_CLICK, description: - 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index.', + 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index and match index.', inputSchema: { type: 'object', properties: { @@ -234,6 +373,37 @@ function getTools() { type: 'string', description: 'Required CSS selector for the element to click.', }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + offsetX: { + type: 'number', + description: "Optional horizontal offset in CSS pixels from the target element's left edge.", + }, + offsetY: { + type: 'number', + description: "Optional vertical offset in CSS pixels from the target element's top edge.", + }, + button: { + type: 'string', + enum: ['left', 'middle', 'right'], + description: 'Optional mouse button. Defaults to left.', + }, + clickCount: { + type: 'integer', + minimum: 1, + description: 'Optional click count. Defaults to 1.', + }, }, required: ['selector'], additionalProperties: false, @@ -268,6 +438,484 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_PRESS_KEY, + description: + 'Press a key or key combination in the selected page using real keyboard-style events. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + key: { + type: 'string', + description: 'Required primary key to press.', + }, + modifiers: { + type: 'array', + items: { + type: 'string', + enum: ['Alt', 'Control', 'Meta', 'Shift'], + }, + description: 'Optional modifier keys such as Alt, Control, Meta, or Shift.', + }, + repeat: { + type: 'integer', + minimum: 1, + description: 'Optional repeat count. Defaults to 1.', + }, + }, + required: ['key'], + additionalProperties: false, + }, + }, + { + name: TOOL_SCROLL, + description: + 'Scroll the selected page or a matched element. Supports explicit deltas or scrolling an element into view.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Optional CSS selector for an element-scoped scroll target.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + behavior: { + type: 'string', + enum: ['into-view', 'by-offset'], + description: 'Required scroll behavior.', + }, + deltaX: { + type: 'number', + description: 'Optional horizontal scroll delta for by-offset behavior.', + }, + deltaY: { + type: 'number', + description: 'Optional vertical scroll delta for by-offset behavior.', + }, + }, + required: ['behavior'], + additionalProperties: false, + }, + }, + { + name: TOOL_SELECT_PAGE, + description: 'Select the current page target by page index or page id for subsequent browser tool calls.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_OPEN_PAGE, + description: 'Open a new browser page tab and optionally navigate it to a URL.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_CLOSE_PAGE, + description: 'Close a browser page target by page index or page id. Defaults to the selected page.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_ADD_INTERCEPT_RULE, + description: 'Add a session-local interception rule bound to a concrete page target.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + urlIncludes: { type: 'string' }, + method: { type: 'string' }, + resourceType: { type: 'string' }, + urlPattern: { type: 'string' }, + urlRegex: { type: 'string' }, + headerMatchers: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + valueIncludes: { type: 'string' }, + valueRegex: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + priority: { type: 'integer' }, + action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] }, + statusCode: { type: 'integer', minimum: 100, maximum: 599 }, + responseHeaders: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['name', 'value'], + additionalProperties: false, + }, + }, + body: { type: 'string' }, + contentType: { type: 'string' }, + }, + required: ['action'], + additionalProperties: false, + }, + }, + { + name: TOOL_REMOVE_INTERCEPT_RULE, + description: 'Remove a session-local interception rule by rule id.', + inputSchema: { + type: 'object', + properties: { + ruleId: { type: 'string' }, + }, + required: ['ruleId'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_INTERCEPT_RULES, + description: 'List current session-local interception rules.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_REQUESTS, + description: 'List recent intercepted request summaries for the current session.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + limit: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_SET_DOWNLOAD_BEHAVIOR, + description: 'Set browser-scoped download behavior for the current attach session.', + inputSchema: { + type: 'object', + properties: { + behavior: { type: 'string', enum: ['accept', 'deny'] }, + downloadPath: { type: 'string' }, + eventsEnabled: { type: 'boolean' }, + }, + required: ['behavior'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_DOWNLOADS, + description: 'List recent browser-scoped download summaries recorded in this MCP session.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_DOWNLOAD, + description: 'Cancel an in-progress download by downloadId or guid.', + inputSchema: { + type: 'object', + properties: { + downloadId: { type: 'string' }, + guid: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_SET_FILE_INPUT, + description: + 'Set local files on a file input element in the selected page. Supports selected-page routing plus same-origin frame and open shadow-root scoping.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string' }, + files: { + type: 'array', + items: { type: 'string' }, + }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + }, + required: ['selector', 'files'], + additionalProperties: false, + }, + }, + { + name: TOOL_DRAG_FILES, + description: + 'Drag one or more local files onto a matched page drop target using page-side File and DataTransfer injection.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string' }, + files: { + type: 'array', + items: { type: 'string' }, + }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + }, + required: ['selector', 'files'], + additionalProperties: false, + }, + }, + { + name: TOOL_DRAG_ELEMENT, + description: + 'Drag a matched element to another matched element or explicit coordinates using browser mouse events.', + inputSchema: { + type: 'object', + properties: { + selector: { type: 'string' }, + targetSelector: { type: 'string' }, + targetX: { type: 'number' }, + targetY: { type: 'number' }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + targetNth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + steps: { type: 'integer', minimum: 1 }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_POINTER_ACTION, + description: 'Run a limited pointer action sequence using browser mouse events.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['move', 'down', 'up', 'pause'] }, + selector: { type: 'string' }, + nth: { type: 'integer', minimum: 0 }, + frameSelector: { type: 'string' }, + pierceShadow: { type: 'boolean' }, + x: { type: 'number' }, + y: { type: 'number' }, + button: { type: 'string', enum: ['left', 'middle', 'right'] }, + durationMs: { type: 'integer', minimum: 0 }, + }, + required: ['type'], + additionalProperties: false, + }, + }, + }, + required: ['actions'], + additionalProperties: false, + }, + }, + { + name: TOOL_START_RECORDING, + description: 'Start recording real browser interactions on the selected page and store them as structured steps.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: TOOL_STOP_RECORDING, + description: 'Stop the active browser recording session and keep the recorded result in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_GET_RECORDING, + description: 'Read the current browser recording result from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CLEAR_RECORDING, + description: 'Clear the current browser recording result from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_START_REPLAY, + description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.', + inputSchema: { + type: 'object', + properties: { + steps: { type: 'array', items: { type: 'object' } }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + required: ['steps'], + additionalProperties: false, + }, + }, + { + name: TOOL_GET_REPLAY, + description: 'Read the current replay status and result summary from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_REPLAY, + description: 'Cancel the active replay session and keep the summary in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_START_ORCHESTRATION, + description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', + inputSchema: { + type: 'object', + properties: { + blocks: { type: 'array', items: { type: 'object' } }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + required: ['blocks'], + additionalProperties: false, + }, + }, + { + name: TOOL_GET_ORCHESTRATION, + description: 'Read the current orchestration status and result summary from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_ORCHESTRATION, + description: 'Cancel the active orchestration session and keep the summary in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_EXPORT_ARTIFACT, + description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['kind', 'name'], + additionalProperties: false, + }, + }, + { + name: TOOL_IMPORT_ARTIFACT, + description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_ARTIFACTS, + description: 'List locally saved Browser MCP artifacts.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_DELETE_ARTIFACT, + description: 'Delete a locally saved Browser MCP artifact by name.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, { name: TOOL_TAKE_SCREENSHOT, description: @@ -288,6 +936,202 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_WAIT_FOR, + description: + 'Poll until a selector-scoped or page-level condition is satisfied. Supports selector existence/visibility/text waits and page text waits.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Optional CSS selector for selector-scoped waiting.', + }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + timeoutMs: { + type: 'integer', + minimum: 1, + description: 'Optional timeout in milliseconds.', + }, + pollIntervalMs: { + type: 'integer', + minimum: 1, + description: 'Optional polling interval in milliseconds.', + }, + condition: { + type: 'object', + description: 'Required wait condition.', + }, + }, + required: ['condition'], + additionalProperties: false, + }, + }, + { + name: TOOL_EVAL, + description: + 'Evaluate page-side JavaScript for inspection or mutation, gated by CCS_BROWSER_EVAL_MODE.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + expression: { + type: 'string', + description: 'Required JavaScript expression to evaluate in the page.', + }, + mode: { + type: 'string', + enum: ['readonly', 'readwrite'], + description: 'Optional evaluation mode. Defaults to readonly.', + }, + }, + required: ['expression'], + additionalProperties: false, + }, + }, + { + name: TOOL_HOVER, + description: + 'Move the browser mouse pointer onto the first element matching a CSS selector in the selected page to trigger hover state. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the hover target.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_QUERY, + description: + 'Return diagnostic state for selector-matched elements in the selected page. Optionally choose a page by index, zero-based match index, and a subset of fields.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the query target.', + }, + nth: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based match index for selectors returning multiple elements.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + fields: { + type: 'array', + items: { type: 'string' }, + description: 'Optional list of diagnostic fields to return.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_TAKE_ELEMENT_SCREENSHOT, + description: + 'Capture a PNG screenshot clipped to the first element matching a CSS selector in the selected page. Optionally choose a page by index.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + selector: { + type: 'string', + description: 'Required CSS selector for the screenshot target.', + }, + frameSelector: { + type: 'string', + description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + }, + pierceShadow: { + type: 'boolean', + description: 'When true, search open shadow roots beneath the selected root.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, + { + name: TOOL_WAIT_FOR_EVENT, + description: 'Wait until a page or browser event matching the requested filter is observed.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { + type: 'integer', + minimum: 0, + description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + }, + timeoutMs: { + type: 'integer', + minimum: 1, + description: 'Optional timeout in milliseconds.', + }, + event: { + type: 'object', + description: 'Required event selector.', + }, + }, + required: ['event'], + additionalProperties: false, + }, + }, ]; } @@ -337,30 +1181,381 @@ function parsePageIndex(toolArgs) { return toolArgs.pageIndex; } +function findPageIndexById(pages, pageId) { + return pages.findIndex((page) => page.id === pageId); +} + +function resolveFallbackSelectedPageId(pages, preferredIndex = 0) { + if (pages.length === 0) { + return ''; + } + const safeIndex = Math.min(Math.max(preferredIndex, 0), pages.length - 1); + return pages[safeIndex]?.id || pages[0]?.id || ''; +} + +function parseOptionalPageId(toolArgs) { + return typeof toolArgs?.pageId === 'string' && toolArgs.pageId.trim() !== '' + ? toolArgs.pageId.trim() + : ''; +} + +function parseInterceptAction(value) { + if (value !== 'continue' && value !== 'fail' && value !== 'fulfill') { + throw new Error('action must be one of: continue, fail, fulfill'); + } + return value; +} + +function parseOptionalStatusCode(value) { + if (value === undefined) { + return 200; + } + if (!Number.isInteger(value) || value < 100 || value > 599) { + throw new Error('statusCode must be an integer between 100 and 599'); + } + return value; +} + +function parseOptionalResponseHeaders(value, contentType) { + const headers = []; + if (Array.isArray(value)) { + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + throw new Error('responseHeaders entries must be objects with name and value'); + } + const name = requireNonEmptyString(entry.name, 'responseHeaders.name'); + const headerValue = String(entry.value ?? ''); + headers.push({ name, value: headerValue }); + } + } else if (value !== undefined) { + throw new Error('responseHeaders must be an array'); + } + if (contentType && !headers.some((header) => header.name.toLowerCase() === 'content-type')) { + headers.push({ name: 'Content-Type', value: contentType }); + } + return headers; +} + +function parseOptionalBody(value) { + if (value === undefined) { + return ''; + } + if (typeof value !== 'string') { + throw new Error('body must be a string'); + } + return value; +} + +function encodeFulfillBody(body) { + return Buffer.from(body, 'utf8').toString('base64'); +} + +function parseOptionalMethod(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'method').toUpperCase(); +} + +function parseOptionalUrlIncludes(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlIncludes'); +} + +function parseOptionalResourceType(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'resourceType'); +} + +function parseOptionalUrlPattern(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlPattern'); +} + +function parseOptionalUrlRegex(value) { + if (value === undefined) { + return ''; + } + const raw = requireNonEmptyString(value, 'urlRegex'); + try { + new RegExp(raw); + } catch { + throw new Error('urlRegex must be a valid regular expression'); + } + return raw; +} + +function parseOptionalPriority(value) { + if (value === undefined) { + return 0; + } + if (!Number.isInteger(value)) { + throw new Error('priority must be an integer'); + } + return value; +} + +function parseOptionalHeaderMatchers(value) { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error('headerMatchers must be an array'); + } + return value.map((entry) => { + if (!entry || typeof entry !== 'object') { + throw new Error('headerMatchers entries must be objects'); + } + const name = requireNonEmptyString(entry.name, 'headerMatchers.name'); + const valueIncludes = + entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); + const valueRegex = + entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); + if (!valueIncludes && !valueRegex) { + throw new Error('headerMatchers entry must include valueIncludes or valueRegex'); + } + if (valueRegex) { + try { + new RegExp(valueRegex); + } catch { + throw new Error('headerMatchers.valueRegex must be a valid regular expression'); + } + } + return { name, valueIncludes, valueRegex }; + }); +} + +function validateInterceptMatcherSet({ + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, +}) { + if (urlPattern && urlRegex) { + throw new Error('urlPattern and urlRegex cannot be used together'); + } + if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) { + throw new Error('at least one matching condition is required'); + } +} + +function createInterceptRuleId() { + return `rule-${nextInterceptRuleCounter++}`; +} + +function removeInterceptStateForPage(pageId) { + for (let index = interceptRules.length - 1; index >= 0; index -= 1) { + if (interceptRules[index].pageId === pageId) { + interceptRules.splice(index, 1); + } + } + for (let index = recentRequests.length - 1; index >= 0; index -= 1) { + if (recentRequests[index].pageId === pageId) { + recentRequests.splice(index, 1); + } + } + const interceptSession = interceptSessionsByPageId.get(pageId); + if (interceptSession) { + interceptSessionsByPageId.delete(pageId); + } +} + +function pushRecentRequest(entry) { + recentRequests.push(entry); + if (recentRequests.length > MAX_RECENT_REQUESTS) { + recentRequests.splice(0, recentRequests.length - MAX_RECENT_REQUESTS); + } +} + +function getSessionDownloadPath() { + if (!sessionDownloadDir) { + sessionDownloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-downloads-')); + } + return sessionDownloadDir; +} + +function ensureWritableDirectory(downloadPath) { + fs.mkdirSync(downloadPath, { recursive: true }); + fs.accessSync(downloadPath, fs.constants.W_OK); + return downloadPath; +} + +function pushRecentDownload(entry) { + recentDownloads.push(entry); + if (recentDownloads.length > MAX_RECENT_DOWNLOADS) { + recentDownloads.splice(0, recentDownloads.length - MAX_RECENT_DOWNLOADS); + } +} + +function updateRecentDownload(guid, patch) { + const record = recentDownloads.find((candidate) => candidate.guid === guid); + if (!record) { + return null; + } + Object.assign(record, patch); + return record; +} + +function resolveDownloadStatus(progressState) { + if (progressState === 'completed') { + return 'completed'; + } + if (progressState === 'canceled') { + return 'canceled'; + } + return 'inProgress'; +} + +function formatInterceptRules(rules) { + if (rules.length === 0) { + return 'status: empty'; + } + return rules + .map((rule) => + [ + `ruleId: ${rule.ruleId}`, + `pageId: ${rule.pageId}`, + `pageTitle: ${rule.pageTitleSnapshot || ''}`, + `urlIncludes: ${rule.urlIncludes || ''}`, + `method: ${rule.method || ''}`, + `resourceType: ${rule.resourceType || ''}`, + `urlPattern: ${rule.urlPattern || ''}`, + `urlRegex: ${rule.urlRegex || ''}`, + `headerMatchers: ${Array.isArray(rule.headerMatchers) ? rule.headerMatchers.length : 0}`, + `priority: ${Number.isInteger(rule.priority) ? rule.priority : 0}`, + `action: ${rule.action}`, + `statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`, + `contentType: ${rule.action === 'fulfill' ? rule.contentType || '' : 'n/a'}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function formatRecentRequests(entries) { + if (entries.length === 0) { + return 'status: empty'; + } + return entries + .map((entry) => + [ + `requestId: ${entry.requestId}`, + `pageId: ${entry.pageId}`, + `url: ${entry.url}`, + `method: ${entry.method}`, + `resourceType: ${entry.resourceType || ''}`, + `matchedRuleId: ${entry.matchedRuleId || 'none'}`, + `action: ${entry.action}`, + `statusCode: ${entry.action === 'fulfill' ? entry.statusCode : 'n/a'}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function formatRecentDownloads(entries) { + if (entries.length === 0) { + return 'status: empty'; + } + return entries + .map((entry) => + [ + `downloadId: ${entry.downloadId}`, + `guid: ${entry.guid}`, + `pageId: ${entry.pageId || ''}`, + `url: ${entry.url}`, + `suggestedFilename: ${entry.suggestedFilename}`, + `status: ${entry.status}`, + `savedPath: ${entry.savedPath || ''}`, + `startedAt: ${entry.startedAt}`, + `finishedAt: ${entry.finishedAt || ''}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, options = {}) { + const hasPageIndex = toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex'); + const pageId = parseOptionalPageId(toolArgs); + const allowImplicitFallback = options.allowImplicitFallback !== false; + if (hasPageIndex && pageId) { + throw new Error('pageIndex and pageId cannot be used together'); + } + if (hasPageIndex) { + const pageIndex = parsePageIndex(toolArgs); + const page = pages[pageIndex]; + if (!page) { + throw new Error(`pageIndex out of range: ${pageIndex}`); + } + return { page, pageIndex }; + } + if (pageId) { + const pageIndex = findPageIndexById(pages, pageId); + if (pageIndex === -1) { + throw new Error(`page not found: ${pageId}`); + } + return { page: pages[pageIndex], pageIndex }; + } + const selectedIndex = findPageIndexById(pages, defaultSelectedId); + if (selectedIndex === -1) { + if (!allowImplicitFallback && defaultSelectedId) { + throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); + } + const fallbackPage = pages[0]; + if (!fallbackPage) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + return { page: fallbackPage, pageIndex: 0 }; + } + return { page: pages[selectedIndex], pageIndex: selectedIndex }; +} + async function getSelectedPage(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const pageIndex = parsePageIndex(toolArgs); + if (toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) { + const pageIndex = parsePageIndex(toolArgs); + const page = pages[pageIndex]; + if (!page) { + throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); + } + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + return { page, pageIndex, pages }; + } + + let pageIndex = findPageIndexById(pages, selectedPageId); + if (pageIndex === -1) { + selectedPageId = resolveFallbackSelectedPageId(pages, 0); + pageIndex = findPageIndexById(pages, selectedPageId); + } const page = pages[pageIndex]; - if (!page) { - throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); - } - if (!page.webSocketDebuggerUrl) { - throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + if (!page || !page.webSocketDebuggerUrl) { + throw new Error('Browser MCP could not resolve a selected page target.'); } return { page, pageIndex, pages }; } -function formatSessionInfo(pages) { +function formatSessionInfo(pages, selectedId) { return [ '[CCS Browser Session]', '', - ...pages.map((page, index) => `${index}. ${page.title || ''} | ${page.url || ''}`), + ...pages.map((page, index) => { + const selectedSuffix = page.id === selectedId ? ' | selected: true' : ''; + return `${index}. ${page.title || ''} | ${page.url || ''}${selectedSuffix}`; + }), ].join('\n'); } @@ -455,6 +1650,177 @@ async function sendCdpCommand(page, method, params = {}) { }); } +async function getBrowserTarget() { + const targets = await fetchJson(`${getHttpUrl()}/json/list`); + const browserTarget = Array.isArray(targets) + ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') + : null; + if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + throw new Error('browser-level download events are unavailable'); + } + return browserTarget; +} + +async function sendBrowserCdpCommand(method, params = {}) { + const browserTarget = await getBrowserTarget(); + const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); + const requestId = ++requestCounter; + + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + abortSocket(ws); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + } + }, CDP_TIMEOUT_MS); + + function settleError(error) { + if (settled) { + return; + } + clearTimeout(timer); + settled = true; + reject(toSocketError(error)); + } + + addSocketListener(ws, 'open', () => { + ws.send(JSON.stringify({ id: requestId, method, params })); + }); + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + if (settled) { + return; + } + + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + + if (message.id !== requestId) { + return; + } + + clearTimeout(timer); + settled = true; + closeSocket(ws); + + if (message.error) { + reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + + resolve(message.result || null); + })().catch(settleError); + }); + + addSocketListener(ws, 'error', settleError); + addSocketListener(ws, 'close', () => { + if (!settled) { + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + } + }); + }); +} + +async function ensureBrowserDownloadSession() { + if (browserDownloadSession) { + await browserDownloadSession.ready; + return browserDownloadSession; + } + + const browserTarget = await getBrowserTarget(); + const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); + let resolveReady; + let rejectReady; + let activityChain = Promise.resolve(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + browserDownloadSession = { + ws, + ready, + getLastActivity() { + return activityChain; + }, + }; + + addSocketListener(ws, 'open', () => { + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + }); + + addSocketListener(ws, 'message', (data) => { + const activity = (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + + if (message?.method === 'Browser.downloadWillBegin') { + pushRecentDownload({ + downloadId: `download-${nextDownloadCounter++}`, + guid: String(message.params?.guid || ''), + pageId: '', + url: String(message.params?.url || ''), + suggestedFilename: String(message.params?.suggestedFilename || ''), + status: 'started', + savedPath: '', + receivedBytes: 0, + totalBytes: 0, + startedAt: new Date().toISOString(), + finishedAt: '', + }); + return; + } + + if (message?.method === 'Browser.downloadProgress') { + const status = resolveDownloadStatus(String(message.params?.state || 'inProgress')); + updateRecentDownload(String(message.params?.guid || ''), { + status, + receivedBytes: Number(message.params?.receivedBytes || 0), + totalBytes: Number(message.params?.totalBytes || 0), + savedPath: typeof message.params?.filePath === 'string' ? message.params.filePath : '', + finishedAt: status === 'completed' || status === 'canceled' ? new Date().toISOString() : '', + }); + } + })(); + activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + void activity.catch(() => {}); + }); + + addSocketListener(ws, 'close', () => { + if (rejectReady) { + rejectReady(new Error('Browser MCP lost the DevTools websocket connection.')); + } + browserDownloadSession = null; + }); + + addSocketListener(ws, 'error', (error) => { + if (rejectReady) { + rejectReady(toSocketError(error)); + } + browserDownloadSession = null; + }); + + await ready; + return browserDownloadSession; +} + async function evaluateInPage(page, kind) { const response = await sendCdpCommand(page, 'Runtime.evaluate', { expression: createEvaluateExpression(kind), @@ -491,6 +1857,140 @@ async function evaluateExpression(page, expression) { return typeof result.value === 'string' ? result.value : result.value ?? ''; } +async function withPageCommandSession(page, callback) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + let resolveReady; + let rejectReady; + let settled = false; + const pendingCommands = new Map(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + function settleReadyError(error) { + if (rejectReady) { + rejectReady(error instanceof Error ? error : new Error(String(error))); + rejectReady = null; + resolveReady = null; + } + } + + function rejectPendingCommands(error) { + for (const pending of pendingCommands.values()) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + pendingCommands.clear(); + } + + addSocketListener(ws, 'open', () => { + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + }); + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (!message?.id || !pendingCommands.has(message.id)) { + return; + } + const pending = pendingCommands.get(message.id); + pendingCommands.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + pending.resolve(message.result || null); + })().catch((error) => { + const socketError = toSocketError(error); + if (!settled) { + settleReadyError(socketError); + rejectPendingCommands(socketError); + } + }); + }); + + addSocketListener(ws, 'close', () => { + const error = new Error('Browser MCP lost the DevTools websocket connection.'); + if (!settled) { + settleReadyError(error); + rejectPendingCommands(error); + } + }); + + addSocketListener(ws, 'error', (error) => { + const socketError = toSocketError(error); + if (!settled) { + settleReadyError(socketError); + rejectPendingCommands(socketError); + } + }); + + await ready; + + const session = { + async sendCommand(method, params = {}) { + const requestId = ++requestCounter; + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingCommands.delete(requestId); + reject(new Error('Browser MCP timed out waiting for a DevTools response.')); + }, CDP_TIMEOUT_MS); + pendingCommands.set(requestId, { + resolve(result) { + clearTimeout(timer); + resolve(result); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); + ws.send(JSON.stringify({ id: requestId, method, params })); + }); + }, + }; + + try { + return await callback(session); + } finally { + settled = true; + rejectPendingCommands(new Error('Browser MCP closed the page command session.')); + closeSocket(ws); + } +} + +async function evaluateObjectHandle(session, expression) { + const response = await session.sendCommand('Runtime.evaluate', { + expression, + returnByValue: false, + }); + + const result = response && response.result ? response.result : null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + + if (result.subtype === 'error') { + throw new Error(result.description || 'DevTools evaluation returned an error.'); + } + + if (typeof result.objectId !== 'string' || !result.objectId) { + throw new Error('Browser MCP could not resolve a file input handle.'); + } + + return result.objectId; +} + function requireNonEmptyString(value, label) { if (typeof value !== 'string' || value.trim() === '') { throw new Error(`${label} is required`); @@ -521,6 +2021,1108 @@ function requireValidHttpUrl(value) { return parsed.toString(); } +function parseQueryFields(value) { + if (value === undefined) { + return DEFAULT_QUERY_FIELDS; + } + if (!Array.isArray(value) || value.some((field) => typeof field !== 'string')) { + throw new Error('fields must be an array of strings'); + } + const unknownField = value.find((field) => !SUPPORTED_QUERY_FIELD_SET.has(field)); + if (unknownField) { + throw new Error(`unknown query field: ${unknownField}`); + } + return value; +} + +function requireOptionalNonNegativeInteger(value, label) { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function requirePositiveIntegerOrDefault(value, label, fallback) { + if (value === undefined) { + return fallback; + } + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function requirePositiveInteger(value, label) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; +} + +function requireFiniteNumber(value, label) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number`); + } + return value; +} + +function requireEnumString(value, label, allowedValues) { + if (value === undefined) { + return undefined; + } + const normalized = requireNonEmptyString(value, label); + if (!allowedValues.includes(normalized)) { + throw new Error(`${label} must be one of: ${allowedValues.join(', ')}`); + } + return normalized; +} + +function requireOptionalStringArray(value, label, allowedValues) { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error(`${label} must be an array of non-empty strings`); + } + const normalized = value.map((item) => item.trim()); + if (Array.isArray(allowedValues) && allowedValues.length > 0) { + for (const item of normalized) { + if (!allowedValues.includes(item)) { + throw new Error(`${label} must only contain: ${allowedValues.join(', ')}`); + } + } + } + return normalized; +} + +function requireNonEmptyStringArray(value, label) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array of non-empty strings`); + } + if (value.some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error(`${label} must be a non-empty array of non-empty strings`); + } + return value.map((item) => item.trim()); +} + +function requireNonNegativeInteger(value, label) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value; +} + +function getBrowserEvalMode() { + const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim(); + if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') { + return raw; + } + return 'readonly'; +} + +function createSessionCanceledError(kind) { + const error = new Error(`${kind} canceled`); + error.code = SESSION_CANCELED_ERROR_CODE; + return error; +} + +function isSessionCanceledError(error) { + return Boolean(error && typeof error === 'object' && error.code === SESSION_CANCELED_ERROR_CODE); +} + +function throwIfSessionCanceled(session, kind) { + if (session?.cancelRequested === true) { + throw createSessionCanceledError(kind); + } +} + +function markOrchestrationFailureRecorded(error) { + if (error && typeof error === 'object') { + error.orchestrationFailureRecorded = true; + } + return error; +} + +function wasOrchestrationFailureRecorded(error) { + return Boolean(error && typeof error === 'object' && error.orchestrationFailureRecorded === true); +} + +function normalizeDevtoolsResultValue(result) { + if (!result || typeof result !== 'object') { + return { serializable: false, value: undefined }; + } + if (Object.prototype.hasOwnProperty.call(result, 'value')) { + return { serializable: true, value: result.value }; + } + if (Object.prototype.hasOwnProperty.call(result, 'unserializableValue')) { + return { serializable: true, value: result.unserializableValue }; + } + if (result.type === 'undefined') { + return { serializable: true, value: undefined }; + } + return { serializable: false, value: undefined }; +} + +function requireArtifactName(value, label = 'name') { + const name = requireNonEmptyString(value, label); + if (!SAFE_ARTIFACT_NAME_PATTERN.test(name)) { + throw new Error( + 'artifact name must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens' + ); + } + return name; +} + +function parseOptionalNonEmptyString(value) { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''; +} + +function formatScopedSelectorSuffix(frameSelector, pierceShadow) { + return `${frameSelector ? `\nframeSelector: ${frameSelector}` : ''}${pierceShadow ? '\npierceShadow: true' : ''}`; +} + +function buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow) { + return `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + + const count = matches.length; + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + return JSON.stringify({ + exists: nth === undefined ? count > 0 : count > targetIndex, + count, + targetIndex, + targetMissing: true, + }); + } + + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const text = typeof element.innerText === 'string' ? element.innerText : (element.textContent || ''); + return JSON.stringify({ + exists: true, + count, + targetIndex, + connected: element.isConnected, + text, + innerText: typeof element.innerText === 'string' ? element.innerText : '', + textContent: element.textContent || '', + boundingClientRect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + href: typeof element.getAttribute === 'function' ? element.getAttribute('href') || '' : '', + onclick: typeof element.getAttribute === 'function' ? element.getAttribute('onclick') || '' : '', + interactable: + element.isConnected && + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0, + centerPoint: { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }, + }); + })()`; +} + +async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow) { + const raw = await evaluateExpression(page, buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow)); + return JSON.parse(raw); +} + +function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow) { + return `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + if (!(element instanceof HTMLInputElement) || element.type !== 'file') { + throw new Error('element is not a file input for selector: ' + selector); + } + return element; + })()`; +} + +function validateLocalFiles(files) { + return files.map((filePath) => { + const resolvedPath = path.resolve(filePath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`file does not exist: ${resolvedPath}`); + } + const stat = fs.statSync(resolvedPath); + if (!stat.isFile()) { + throw new Error(`file is not a regular file: ${resolvedPath}`); + } + return resolvedPath; + }); +} + +function formatFileInputResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) { + const lines = [ + `pageIndex: ${pageIndex}`, + `selector: ${selector}`, + `nth: ${nth}`, + `fileCount: ${files.length}`, + ]; + if (frameSelector) { + lines.push(`frameSelector: ${frameSelector}`); + } + if (pierceShadow) { + lines.push('pierceShadow: true'); + } + lines.push('status: files-set'); + return lines.join('\n'); +} + +function inferMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.png') return 'image/png'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.txt') return 'text/plain'; + if (ext === '.json') return 'application/json'; + return 'application/octet-stream'; +} + +function readLocalFilesForDrop(files) { + return validateLocalFiles(files).map((resolvedPath) => { + const buffer = fs.readFileSync(resolvedPath); + return { + path: resolvedPath, + name: path.basename(resolvedPath), + mimeType: inferMimeType(resolvedPath), + size: buffer.length, + base64: buffer.toString('base64'), + }; + }); +} + +function buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, files) { + return `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + const filePayloads = JSON.parse(${JSON.stringify(JSON.stringify(files))}); + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + + const files = filePayloads.map((file) => { + const binary = atob(file.base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], file.name, { type: file.mimeType }); + }); + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + const dragEnterEvent = new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + const dragOverEvent = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + + element.dispatchEvent(dragEnterEvent); + const dragOverCanceled = + element.dispatchEvent(dragOverEvent) === false || dragOverEvent.defaultPrevented; + const dropCanceled = element.dispatchEvent(dropEvent) === false || dropEvent.defaultPrevented; + + return { accepted: dragOverCanceled || dropCanceled }; + })()`; +} + +function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) { + const lines = [ + `pageIndex: ${pageIndex}`, + `selector: ${selector}`, + `nth: ${nth}`, + `fileCount: ${files.length}`, + ]; + if (frameSelector) { + lines.push(`frameSelector: ${frameSelector}`); + } + if (pierceShadow) { + lines.push('pierceShadow: true'); + } + lines.push('status: files-dropped'); + return lines.join('\n'); +} + +function createRecordingId() { + const recordingId = `rec_${String(nextRecordingCounter).padStart(4, '0')}`; + nextRecordingCounter += 1; + return recordingId; +} + +function createReplayId() { + const replayId = `rep_${String(nextReplayCounter).padStart(4, '0')}`; + nextReplayCounter += 1; + return replayId; +} + +function requireLatestRecording() { + if (!latestRecordingSession) { + throw new Error('no recording available'); + } + return latestRecordingSession; +} + +function formatRecordingSummary(session) { + return [ + `recordingId: ${session.recordingId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `stepCount: ${session.steps.length}`, + `warningCount: ${session.warnings.length}`, + `status: ${session.status}`, + ].join('\n'); +} + +function getArtifactDir() { + const scoped = Buffer.from(process.cwd()).toString('base64url'); + const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped); + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 }); + } + try { + fs.chmodSync(baseDir, 0o700); + } catch { + // Best-effort on platforms/filesystems that ignore permission changes. + } + return baseDir; +} + +function getArtifactPath(name) { + const safeName = requireArtifactName(name); + return path.join(getArtifactDir(), `${safeName}.json`); +} + +function buildArtifact(kind, name, payload) { + return { + kind, + version: 1, + name, + createdAt: new Date().toISOString(), + payload, + }; +} + +function readArtifactFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error('artifact file not found'); + } + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + throw new Error('artifact file is not a regular file'); + } + if (stat.size > MAX_ARTIFACT_FILE_BYTES) { + throw new Error( + `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` + ); + } + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('invalid artifact payload'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('invalid artifact payload'); + } + if (parsed.version !== 1) { + throw new Error('unsupported artifact version'); + } + if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) { + throw new Error('invalid artifact payload'); + } + return parsed; +} + +function createOrchestrationId() { + const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`; + nextOrchestrationCounter += 1; + return orchestrationId; +} + +function requireLatestReplay() { + if (!latestReplaySession) { + throw new Error('no replay available'); + } + return latestReplaySession; +} + +function requireLatestOrchestration() { + if (!latestOrchestrationSession) { + throw new Error('no orchestration available'); + } + return latestOrchestrationSession; +} + +function formatReplaySummary(session) { + return [ + `replayId: ${session.replayId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `stepCount: ${session.stepCount}`, + `completedSteps: ${session.completedSteps}`, + `currentStepIndex: ${session.currentStepIndex}`, + `failedStepIndex: ${session.failedStepIndex === null ? 'none' : session.failedStepIndex}`, + `error: ${session.error || 'none'}`, + `status: ${session.status}`, + ].join('\n'); +} + +function formatOrchestrationSummary(session) { + const lines = [ + `orchestrationId: ${session.orchestrationId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `blockCount: ${session.blockCount}`, + `completedBlocks: ${session.completedBlocks}`, + `failedCount: ${session.failedCount || 0}`, + `currentBlockIndex: ${session.currentBlockIndex}`, + `failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`, + `error: ${session.error || 'none'}`, + `status: ${session.status}`, + ]; + if (Array.isArray(session.failures) && session.failures.length > 0) { + session.failures.forEach((failure, index) => { + lines.push(`failure[${index}].blockIndex: ${failure.blockIndex}`); + lines.push( + `failure[${index}].sequenceStepIndex: ${failure.sequenceStepIndex === null ? 'none' : failure.sequenceStepIndex}` + ); + lines.push(`failure[${index}].type: ${failure.type}`); + lines.push(`failure[${index}].message: ${failure.message}`); + }); + } + if (session.failedSequenceStepIndex !== null && session.failedSequenceStepIndex !== undefined) { + lines.push(`failedSequenceStepIndex: ${session.failedSequenceStepIndex}`); + } + if (session.errorDetails?.failedAssertionIndex !== undefined) { + lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`); + lines.push(`field: ${session.errorDetails.field}`); + lines.push(`op: ${session.errorDetails.op}`); + lines.push(`expected: ${JSON.stringify(session.errorDetails.expected)}`); + lines.push(`actual: ${JSON.stringify(session.errorDetails.actual)}`); + } + return lines.join('\n'); +} + +async function waitForSessionToSettle(task) { + await Promise.race([ + task.then(() => undefined), + sleep(SESSION_START_SETTLE_WINDOW_MS), + ]); +} + +function createReplaySession(page, pageIndex, steps) { + return { + replayId: createReplayId(), + pageId: page.id, + pageIndex, + status: 'running', + stepCount: steps.length, + completedSteps: 0, + currentStepIndex: 0, + failedStepIndex: null, + error: null, + cancelRequested: false, + steps, + }; +} + +function createOrchestrationSession(page, pageIndex, blocks) { + return { + orchestrationId: createOrchestrationId(), + pageId: page.id, + pageIndex, + status: 'running', + blockCount: blocks.length, + completedBlocks: 0, + currentBlockIndex: 0, + failedBlockIndex: null, + failedSequenceStepIndex: null, + error: null, + failedCount: 0, + failures: [], + cancelRequested: false, + blocks, + }; +} + +function formatRecordingDetail(session) { + const lines = [formatRecordingSummary(session), 'steps:']; + if (session.steps.length === 0) { + lines.push('[]'); + } else { + session.steps.forEach((step, index) => { + lines.push(`- [${index}] type: ${step.type}`); + lines.push(` pageId: ${step.pageId}`); + lines.push(` timestamp: ${step.timestamp}`); + if (step.selector) { + lines.push(` selector: ${step.selector}`); + } + if (step.frameSelector) { + lines.push(` frameSelector: ${step.frameSelector}`); + } + if (step.pierceShadow === true) { + lines.push(' pierceShadow: true'); + } + if (step.args && typeof step.args === 'object') { + for (const [key, value] of Object.entries(step.args)) { + if (value !== undefined) { + lines.push(` ${key}: ${JSON.stringify(value)}`); + } + } + } + }); + } + + lines.push('warnings:'); + if (session.warnings.length === 0) { + lines.push('[]'); + } else { + session.warnings.forEach((warning) => { + lines.push(`- ${warning}`); + }); + } + + return lines.join('\n'); +} + +const SUPPORTED_REPLAY_STEP_TYPES = new Set([ + 'click', + 'type', + 'press_key', + 'scroll', + 'drag_element', + 'pointer_action', +]); + +function requireReplaySteps(toolArgs) { + const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps : null; + if (!steps || steps.length === 0) { + throw new Error('steps must be a non-empty array'); + } + return steps; +} + +function validateReplayStep(step, replayPageId) { + if (!step || typeof step !== 'object') { + throw new Error('invalid replay step payload'); + } + if (!SUPPORTED_REPLAY_STEP_TYPES.has(step.type)) { + throw new Error('unsupported replay step type'); + } + if (step.pageId && step.pageId !== replayPageId) { + throw new Error('replay step pageId mismatch'); + } +} + +const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ + 'wait_for_then_click', + 'wait_for_then_type', + 'wait_for_then_press_key', + 'run_replay_sequence', + 'assert_query', + 'sequence', + 'open_page_then_run', + 'select_page_then_run', + 'close_page_then_continue', +]); + +const CROSS_PAGE_BLOCK_TYPES = new Set([ + 'open_page_then_run', + 'select_page_then_run', + 'close_page_then_continue', +]); + +const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']); + +function parseAssertionValue(value) { + return value; +} + +function toComparableNumber(field, value) { + const numeric = Number(value); + if (Number.isNaN(numeric)) { + throw new Error(`numeric comparison expects a number-like field: ${field}`); + } + return numeric; +} + +function parseAssertions(assertQueryArgs) { + if (Array.isArray(assertQueryArgs.assertions) && assertQueryArgs.assertions.length > 0) { + return assertQueryArgs.assertions.map((entry, index) => { + if (!entry || typeof entry !== 'object') { + throw new Error(`invalid assertion payload at index ${index}`); + } + const field = requireNonEmptyString(entry.field, 'assertion.field'); + const op = requireNonEmptyString(entry.op, 'assertion.op'); + if (!SUPPORTED_ASSERTION_OPERATORS.has(op)) { + throw new Error('unsupported assertion operator'); + } + if (!Object.prototype.hasOwnProperty.call(entry, 'value')) { + throw new Error('assertion value is required'); + } + return { field, op, value: parseAssertionValue(entry.value) }; + }); + } + + if (assertQueryArgs.assert && typeof assertQueryArgs.assert === 'object') { + return [ + { + field: requireNonEmptyString(assertQueryArgs.assert.field, 'assert.field'), + op: 'equals', + value: assertQueryArgs.assert.equals, + }, + ]; + } + + throw new Error('assertions must be a non-empty array'); +} + +function parseQueryTextToMap(text) { + const result = {}; + for (const line of String(text || '').split('\n')) { + const separatorIndex = line.indexOf(': '); + if (separatorIndex === -1) { + continue; + } + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 2).trim(); + result[key] = rawValue; + } + return result; +} + +function evaluateAssertion(assertion, actualValue, assertionIndex) { + const { field, op, value: expected } = assertion; + + if (op === 'equals') { + return String(actualValue) === String(expected) + ? null + : { + failedAssertionIndex: assertionIndex, + field, + op, + expected, + actual: actualValue, + message: `assert_query failed: ${field} equals ${JSON.stringify(expected)} (actual: ${JSON.stringify(actualValue)})`, + }; + } + + if (op === 'contains') { + if (typeof actualValue !== 'string') { + throw new Error('contains expects a string field'); + } + return actualValue.includes(String(expected)) + ? null + : { + failedAssertionIndex: assertionIndex, + field, + op, + expected, + actual: actualValue, + message: `assert_query failed: ${field} contains ${JSON.stringify(expected)} (actual: ${JSON.stringify(actualValue)})`, + }; + } + + const actualNumber = toComparableNumber(field, actualValue); + const expectedNumber = toComparableNumber(field, expected); + const passed = + op === 'gt' + ? actualNumber > expectedNumber + : op === 'gte' + ? actualNumber >= expectedNumber + : op === 'lt' + ? actualNumber < expectedNumber + : actualNumber <= expectedNumber; + + return passed + ? null + : { + failedAssertionIndex: assertionIndex, + field, + op, + expected: expectedNumber, + actual: actualNumber, + message: `assert_query failed: ${field} ${op} ${expectedNumber} (actual: ${actualNumber})`, + }; +} + +function formatOrchestrationError(error) { + const message = error instanceof Error ? error.message : String(error); + try { + const parsed = JSON.parse(message); + if (parsed && typeof parsed === 'object' && 'failedAssertionIndex' in parsed) { + return parsed; + } + } catch { + // Keep plain-text error fallback below. + } + return { message }; +} + +function pushOrchestrationFailure(session, details) { + session.failures = session.failures || []; + session.failures.push(details); + session.failedCount = session.failures.length; +} + +function requireOrchestrationBlocks(toolArgs) { + const blocks = Array.isArray(toolArgs.blocks) ? toolArgs.blocks : null; + if (!blocks || blocks.length === 0) { + throw new Error('blocks must be a non-empty array'); + } + return blocks; +} + +function validateOrchestrationBlock(block) { + if (!block || typeof block !== 'object') { + throw new Error('invalid orchestration block payload'); + } + if (!SUPPORTED_ORCHESTRATION_BLOCK_TYPES.has(block.type)) { + throw new Error('unsupported orchestration block type'); + } +} + +function requireSequenceSteps(block) { + const steps = Array.isArray(block.args?.steps) ? block.args.steps : null; + if (!steps || steps.length === 0) { + throw new Error('sequence steps must be a non-empty array'); + } + return steps; +} + +function validateSequenceStep(step) { + validateOrchestrationBlock(step); + if (step.type === 'sequence') { + throw new Error('sequence does not support nested sequence blocks'); + } +} + +function requireCrossPageRunBlock(block) { + if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') { + throw new Error('cross-page run block is required'); + } + if (CROSS_PAGE_BLOCK_TYPES.has(block.args.run.type)) { + throw new Error('nested cross-page blocks are not supported'); + } + return block.args.run; +} + +function normalizeRecordedEvent(pageId, event) { + if (!event || typeof event !== 'object') { + return null; + } + + const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now(); + if (event.kind === 'click') { + return { + type: 'click', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + button: event.button || 'left', + clickCount: event.clickCount || 1, + offsetX: event.offsetX, + offsetY: event.offsetY, + }, + }; + } + if (event.kind === 'type') { + return { + type: 'type', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + text: event.text || '', + }, + }; + } + if (event.kind === 'press_key') { + return { + type: 'press_key', + pageId, + timestamp, + args: { + key: event.key, + modifiers: Array.isArray(event.modifiers) ? event.modifiers : [], + }, + }; + } + if (event.kind === 'scroll') { + return { + type: 'scroll', + pageId, + timestamp, + selector: event.selector, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + deltaX: event.deltaX || 0, + deltaY: event.deltaY || 0, + }, + }; + } + if (event.kind === 'drag_element') { + return { + type: 'drag_element', + pageId, + timestamp, + selector: event.selector, + nth: event.nth ?? 0, + frameSelector: event.frameSelector, + pierceShadow: event.pierceShadow === true, + args: { + targetSelector: event.targetSelector, + targetX: event.targetX, + targetY: event.targetY, + targetNth: event.targetNth, + }, + }; + } + if (event.kind === 'pointer_action') { + return { + type: 'pointer_action', + pageId, + timestamp, + args: { + actions: Array.isArray(event.actions) ? event.actions : [], + }, + }; + } + return null; +} + +function parseEventCondition(value) { + if (!value || typeof value !== 'object') { + throw new Error('event is required'); + } + if (value.kind === 'dialog') { + return { + kind: 'dialog', + dialogType: value.dialogType ? String(value.dialogType) : undefined, + messageIncludes: value.messageIncludes ? String(value.messageIncludes) : undefined, + }; + } + if (value.kind === 'navigation') { + return { + kind: 'navigation', + urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, + }; + } + if (value.kind === 'request') { + return { + kind: 'request', + urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, + method: value.method ? String(value.method) : undefined, + }; + } + if (value.kind === 'download') { + return { + kind: 'download', + urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, + suggestedFilenameIncludes: value.suggestedFilenameIncludes + ? String(value.suggestedFilenameIncludes) + : undefined, + }; + } + throw new Error(`unknown event kind: ${String(value.kind || '')}`); +} + +function matchesObservedEvent(event, observed) { + if (event.kind === 'dialog') { + return ( + (!event.dialogType || observed.type === event.dialogType) && + (!event.messageIncludes || String(observed.message || '').includes(event.messageIncludes)) + ); + } + if (event.kind === 'navigation') { + return !event.urlIncludes || String(observed.url || '').includes(event.urlIncludes); + } + if (event.kind === 'request') { + return ( + (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && + (!event.method || String(observed.method || '').toUpperCase() === event.method.toUpperCase()) + ); + } + if (event.kind === 'download') { + return ( + (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && + (!event.suggestedFilenameIncludes || String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) + ); + } + return false; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -601,12 +3203,65 @@ async function handleNavigate(toolArgs) { async function handleClick(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const targetIndex = nth ?? 0; + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const offsetX = toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX'); + const offsetY = toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY'); + const button = requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left'; + const clickCount = toolArgs.clickCount === undefined ? 1 : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); - const element = document.querySelector(selector); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + const offsetX = ${offsetX === undefined ? 'undefined' : String(offsetX)}; + const offsetY = ${offsetY === undefined ? 'undefined' : String(offsetY)}; + const button = JSON.parse(${JSON.stringify(JSON.stringify(button))}); + const clickCount = ${clickCount}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + const element = matches[nth ?? 0]; if (!element) { - throw new Error('element not found for selector: ' + selector); + throw new Error('element index ' + (nth ?? 0) + ' is out of range for selector: ' + selector); } if (!element.isConnected) { throw new Error('element is detached for selector: ' + selector); @@ -615,52 +3270,104 @@ async function handleClick(toolArgs) { throw new Error('element is disabled for selector: ' + selector); } const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); + const initialRect = element.getBoundingClientRect(); if ( style.display === 'none' || style.visibility === 'hidden' || - rect.width <= 0 || - rect.height <= 0 + initialRect.width <= 0 || + initialRect.height <= 0 ) { throw new Error('element is hidden or not interactable for selector: ' + selector); } element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); - const dispatchMouseEvent = (type, init) => { + const resolvedOffsetX = offsetX === undefined ? rect.width / 2 : offsetX; + const resolvedOffsetY = offsetY === undefined ? rect.height / 2 : offsetY; + const clientX = rect.left + resolvedOffsetX; + const clientY = rect.top + resolvedOffsetY; + const buttonCode = button === 'middle' ? 1 : button === 'right' ? 2 : 0; + const buttonsMask = buttonCode === 1 ? 4 : buttonCode === 2 ? 2 : 1; + + const dispatchMouseEvent = (type, detail, init) => { const event = new MouseEvent(type, { bubbles: true, cancelable: true, composed: true, view: window, - detail: 1, + detail, + clientX, + clientY, + button: buttonCode, + buttons: type === 'mousedown' ? buttonsMask : 0, ...init, }); return element.dispatchEvent(event); }; try { - const dispatchResult = { - shouldActivate: - dispatchMouseEvent('mousedown', { button: 0, buttons: 1 }) && - dispatchMouseEvent('mouseup', { button: 0, buttons: 0 }), - }; - if (!dispatchResult.shouldActivate) { - return 'ok'; + for (let index = 1; index <= clickCount; index += 1) { + const dispatchResult = { + shouldActivate: + dispatchMouseEvent('mousedown', index, {}) && + dispatchMouseEvent('mouseup', index, {}), + }; + if (!dispatchResult.shouldActivate) { + return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); + } + if (!element.isConnected) { + return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); + } + if (button === 'left') { + element.click(); + } else if (button === 'right') { + element.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + composed: true, + view: window, + detail: index, + clientX, + clientY, + button: 2, + buttons: 0, + })); + } else { + element.dispatchEvent(new MouseEvent('auxclick', { + bubbles: true, + cancelable: true, + composed: true, + view: window, + detail: index, + clientX, + clientY, + button: 1, + buttons: 0, + })); + } } - if (!element.isConnected) { - return 'ok'; + if (button === 'left' && clickCount === 2) { + element.dispatchEvent(new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + composed: true, + view: window, + detail: 2, + clientX, + clientY, + button: 0, + buttons: 0, + })); } } catch (mouseError) { - // Fall through to the native activation path below. } - element.click(); - - return 'ok'; + return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); })()`; - await evaluateExpression(page, expression); - return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; + const raw = await evaluateExpression(page, expression); + const parsed = JSON.parse(raw); + return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\noffsetX: ${parsed.resolvedOffsetX}\noffsetY: ${parsed.resolvedOffsetY}\nbutton: ${parsed.button}\nclickCount: ${parsed.clickCount}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: clicked`; } async function handleType(toolArgs) { @@ -742,6 +3449,183 @@ async function handleType(toolArgs) { return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; } +async function handlePressKey(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const key = requireNonEmptyString(toolArgs.key, 'key'); + const modifiers = requireOptionalStringArray(toolArgs.modifiers, 'modifiers', [ + 'Alt', + 'Control', + 'Meta', + 'Shift', + ]); + const repeat = toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); + const modifierMask = + (modifiers.includes('Alt') ? 1 : 0) | + (modifiers.includes('Control') ? 2 : 0) | + (modifiers.includes('Meta') ? 4 : 0) | + (modifiers.includes('Shift') ? 8 : 0); + const specialKeyMap = { + Enter: { code: 'Enter', keyCode: 13, text: '\r' }, + Tab: { code: 'Tab', keyCode: 9, text: '' }, + Escape: { code: 'Escape', keyCode: 27, text: '' }, + ArrowUp: { code: 'ArrowUp', keyCode: 38, text: '' }, + ArrowDown: { code: 'ArrowDown', keyCode: 40, text: '' }, + ArrowLeft: { code: 'ArrowLeft', keyCode: 37, text: '' }, + ArrowRight: { code: 'ArrowRight', keyCode: 39, text: '' }, + Backspace: { code: 'Backspace', keyCode: 8, text: '' }, + Delete: { code: 'Delete', keyCode: 46, text: '' }, + Space: { code: 'Space', keyCode: 32, text: ' ' }, + }; + const keyDescriptor = + key.length === 1 + ? { + code: `Key${key.toUpperCase()}`, + keyCode: key.toUpperCase().charCodeAt(0), + text: key, + } + : specialKeyMap[key]; + if (!keyDescriptor) { + throw new Error(`unsupported key: ${key}`); + } + const normalizedKey = key; + const normalizedText = keyDescriptor.text; + const code = keyDescriptor.code; + const keyCode = keyDescriptor.keyCode; + + for (let index = 0; index < repeat; index += 1) { + await sendCdpCommand(page, 'Input.dispatchKeyEvent', { + type: 'keyDown', + key: normalizedKey, + code, + text: normalizedText, + unmodifiedText: normalizedText, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifierMask, + autoRepeat: index > 0, + }); + await sendCdpCommand(page, 'Input.dispatchKeyEvent', { + type: 'keyUp', + key: normalizedKey, + code, + windowsVirtualKeyCode: keyCode, + nativeVirtualKeyCode: keyCode, + modifiers: modifierMask, + }); + } + + const modifierText = modifiers.length > 0 ? modifiers.join(',') : 'none'; + return `pageIndex: ${pageIndex}\nkey: ${key}\nmodifiers: ${modifierText}\nrepeat: ${repeat}\nstatus: key-pressed`; +} + +async function handleScroll(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = parseOptionalNonEmptyString(toolArgs.selector); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['into-view', 'by-offset']); + const deltaX = toolArgs.deltaX === undefined ? 0 : requireFiniteNumber(toolArgs.deltaX, 'deltaX'); + const deltaY = toolArgs.deltaY === undefined ? 0 : requireFiniteNumber(toolArgs.deltaY, 'deltaY'); + + const expression = `(() => { + const selector = ${selector ? `JSON.parse(${JSON.stringify(JSON.stringify(selector))})` : 'undefined'}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + const behavior = JSON.parse(${JSON.stringify(JSON.stringify(behavior))}); + const deltaX = ${deltaX}; + const deltaY = ${deltaY}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + let scrollWindow = window; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + if (!frame.contentWindow) { + throw new Error('frame window is unavailable for selector: ' + frameSelector); + } + root = frameDocument; + scrollWindow = frame.contentWindow; + } + + if (!selector) { + if (behavior !== 'by-offset') { + throw new Error('selector is required for behavior: ' + behavior); + } + scrollWindow.scrollBy(deltaX, deltaY); + return JSON.stringify({ scope: 'page', behavior, deltaX, deltaY }); + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + const element = matches[0]; + if (!element) { + throw new Error('element not found for selector: ' + selector); + } + + if (behavior === 'into-view') { + element.scrollIntoView({ block: 'center', inline: 'center' }); + return JSON.stringify({ scope: 'element', selector, behavior }); + } + + if (typeof element.scrollBy === 'function') { + element.scrollBy(deltaX, deltaY); + return JSON.stringify({ scope: 'element', selector, behavior, deltaX, deltaY }); + } + + throw new Error('element does not support scrollBy for selector: ' + selector); + })()`; + + const raw = await evaluateExpression(page, expression); + const parsed = JSON.parse(raw); + const lines = [`pageIndex: ${pageIndex}`]; + if (parsed.selector) { + lines.push(`selector: ${parsed.selector}`); + } + lines.push(`behavior: ${parsed.behavior}`); + if (typeof parsed.deltaX === 'number') { + lines.push(`deltaX: ${parsed.deltaX}`); + } + if (typeof parsed.deltaY === 'number') { + lines.push(`deltaY: ${parsed.deltaY}`); + } + if (parsed.scope === 'element') { + const scopedSuffix = formatScopedSelectorSuffix(frameSelector, pierceShadow); + if (scopedSuffix) { + lines.push(scopedSuffix.slice(1)); + } + } + lines.push('status: scrolled'); + return lines.join('\n'); +} + async function handleScreenshot(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const fullPage = toolArgs.fullPage === true; @@ -758,13 +3642,2139 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } +async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) { + return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); +} + +async function getScrolledElementStateAt(page, selector, nth, frameSelector = '', pierceShadow = false) { + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; + const pierceShadow = ${pierceShadow ? 'true' : 'false'}; + + const visitRoots = (root) => { + const roots = [root]; + if (!pierceShadow) { + return roots; + } + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift(); + const elements = Array.from(current.querySelectorAll('*')); + for (const element of elements) { + if (element.shadowRoot) { + roots.push(element.shadowRoot); + queue.push(element.shadowRoot); + } + } + } + return roots; + }; + + let root = document; + let frameOffset = { left: 0, top: 0 }; + if (frameSelector) { + const frame = document.querySelector(frameSelector); + if (!frame) { + throw new Error('frame not found for selector: ' + frameSelector); + } + const frameDocument = frame.contentDocument; + if (!frameDocument) { + throw new Error('frame document is unavailable for selector: ' + frameSelector); + } + const frameRect = frame.getBoundingClientRect(); + frameOffset = { left: frameRect.left, top: frameRect.top }; + root = frameDocument; + } + + const roots = visitRoots(root); + const matches = []; + for (const currentRoot of roots) { + matches.push(...Array.from(currentRoot.querySelectorAll(selector))); + } + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; + if (!element) { + return JSON.stringify({ exists: false }); + } + if (!element.isConnected) { + return JSON.stringify({ exists: true, connected: false }); + } + element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); + const absoluteRect = { + x: rect.x + frameOffset.left, + y: rect.y + frameOffset.top, + width: rect.width, + height: rect.height, + top: rect.top + frameOffset.top, + right: rect.right + frameOffset.left, + bottom: rect.bottom + frameOffset.top, + left: rect.left + frameOffset.left, + }; + const style = window.getComputedStyle(element); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + const clipX = Math.max(0, absoluteRect.left); + const clipY = Math.max(0, absoluteRect.top); + const clipRight = Math.min(viewportWidth, absoluteRect.right); + const clipBottom = Math.min(viewportHeight, absoluteRect.bottom); + return JSON.stringify({ + exists: true, + connected: true, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + interactable: + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0, + boundingClientRect: absoluteRect, + centerPoint: { + x: absoluteRect.left + absoluteRect.width / 2, + y: absoluteRect.top + absoluteRect.height / 2, + }, + visibleClip: { + x: clipX, + y: clipY, + width: Math.max(0, clipRight - clipX), + height: Math.max(0, clipBottom - clipY), + scale: 1, + }, + }); + })()`; + return JSON.parse(await evaluateExpression(page, expression)); +} + +async function getScrolledElementState(page, selector, frameSelector = '', pierceShadow = false) { + return await getScrolledElementStateAt(page, selector, undefined, frameSelector, pierceShadow); +} + +function formatQueryValue(field, value) { + if (field === 'boundingClientRect') { + return JSON.stringify(value); + } + return String(value); +} + +function hasTargetSpecificQueryField(fields) { + return fields.some((field) => field !== 'exists' && field !== 'count'); +} + +function parseWaitCondition(value, hasSelector) { + if (!value || typeof value !== 'object') { + throw new Error('condition is required'); + } + const condition = value; + if (condition.kind === 'existence') { + if (!hasSelector) { + throw new Error('page-level wait only supports text conditions in Phase 1'); + } + return { kind: 'existence', exists: condition.exists !== false }; + } + if (condition.kind === 'visibility') { + if (!hasSelector) { + throw new Error('page-level wait only supports text conditions in Phase 1'); + } + return { + kind: 'visibility', + visibility: condition.visibility === 'hidden' ? 'hidden' : 'visible', + opacityGt: typeof condition.opacityGt === 'number' ? condition.opacityGt : undefined, + }; + } + if (condition.kind === 'text') { + if (typeof condition.includes !== 'string' || condition.includes === '') { + throw new Error('condition.includes is required'); + } + return { kind: 'text', includes: condition.includes }; + } + throw new Error(`unknown wait condition kind: ${String(condition.kind || '')}`); +} + +function isVisibleObservation(observation, opacityGt) { + if (!observation || observation.targetMissing) { + return false; + } + const opacity = Number.parseFloat(String(observation.opacity ?? '1')); + return ( + observation.display !== 'none' && + observation.visibility === 'visible' && + Number(observation.boundingClientRect?.width || 0) > 0 && + Number(observation.boundingClientRect?.height || 0) > 0 && + (opacityGt === undefined || opacity > opacityGt) + ); +} + +function isHiddenObservation(observation) { + if (!observation || observation.targetMissing) { + return true; + } + return ( + observation.display === 'none' || + observation.visibility !== 'visible' || + Number(observation.boundingClientRect?.width || 0) <= 0 || + Number(observation.boundingClientRect?.height || 0) <= 0 + ); +} + +function isWaitConditionSatisfied(observation, condition) { + if (condition.kind === 'existence') { + return condition.exists ? observation.exists === true : observation.exists === false; + } + if (condition.kind === 'visibility') { + return condition.visibility === 'hidden' + ? isHiddenObservation(observation) + : isVisibleObservation(observation, condition.opacityGt); + } + if (condition.kind === 'text') { + return String(observation.text || '').includes(condition.includes); + } + return false; +} + +function formatWaitObservation(observation) { + if (!observation) { + return 'unavailable'; + } + if ('exists' in observation || 'count' in observation || 'display' in observation) { + return [ + `exists=${observation.exists === true ? 'true' : 'false'}`, + `count=${String(observation.count ?? 0)}`, + `display=${String(observation.display ?? '')}`, + `visibility=${String(observation.visibility ?? '')}`, + `opacity=${String(observation.opacity ?? '')}`, + ].join(', '); + } + if ('text' in observation) { + return `text=${JSON.stringify(observation.text || '')}`; + } + return 'unavailable'; +} + +function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) { + const lines = [`pageIndex: ${pageIndex}`, `selector: ${selector}`]; + if (nth !== undefined) { + lines.push(`nth: ${nth}`); + } + if (diagnostics.targetMissing) { + if (hasTargetSpecificQueryField(fields)) { + throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`); + } + for (const field of fields) { + if (field === 'exists') { + lines.push(`exists: ${diagnostics.exists ? 'true' : 'false'}`); + continue; + } + if (field === 'count') { + lines.push(`count: ${formatQueryValue(field, diagnostics.count)}`); + } + } + return lines.join('\n'); + } + for (const field of fields) { + lines.push(`${field}: ${formatQueryValue(field, diagnostics[field])}`); + } + return lines.join('\n'); +} + +async function getWaitPageObservation(page) { + const text = await evaluateExpression( + page, + `(() => document.body ? document.body.innerText || '' : '')()` + ); + return { text }; +} + +async function getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) { + return getElementDiagnostics(page, selector, nth, frameSelector, pierceShadow); +} + +async function handleWaitFor(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = typeof toolArgs.selector === 'string' ? toolArgs.selector.trim() : ''; + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const pollIntervalMs = requirePositiveIntegerOrDefault( + toolArgs.pollIntervalMs, + 'pollIntervalMs', + DEFAULT_WAIT_POLL_INTERVAL_MS + ); + const condition = parseWaitCondition(toolArgs.condition, selector !== ''); + const deadline = Date.now() + timeoutMs; + let lastObserved = null; + + while (Date.now() <= deadline) { + lastObserved = selector + ? await getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) + : await getWaitPageObservation(page); + if (isWaitConditionSatisfied(lastObserved, condition)) { + return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: satisfied`; + } + if (Date.now() + pollIntervalMs > deadline) { + break; + } + await sleep(pollIntervalMs); + } + + throw new Error(`wait condition timed out\nlastObserved: ${formatWaitObservation(lastObserved)}`); +} + +async function handleEval(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const expression = requireNonEmptyString(toolArgs.expression, 'expression'); + const mode = toolArgs.mode === 'readwrite' ? 'readwrite' : 'readonly'; + const evalMode = getBrowserEvalMode(); + + if (evalMode === 'disabled') { + throw new Error('browser_eval is disabled by CCS_BROWSER_EVAL_MODE=disabled'); + } + if (mode === 'readwrite' && evalMode !== 'readwrite') { + throw new Error(`browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=${evalMode}`); + } + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + ...(mode === 'readonly' ? { throwOnSideEffect: true } : {}), + }); + + if (response?.exceptionDetails?.text) { + throw new Error(response.exceptionDetails.text); + } + if (!response?.result) { + throw new Error('evaluation result is not JSON-serializable'); + } + + const result = response.result; + const normalizedResult = normalizeDevtoolsResultValue(result); + if (!normalizedResult.serializable) { + throw new Error('evaluation result is not JSON-serializable'); + } + + return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(normalizedResult.value)}`; +} + +function getButtonMask(button) { + if (button === 'right') { + return 2; + } + if (button === 'middle') { + return 4; + } + return 1; +} + +async function dispatchMousePointerEvent(page, type, x, y, button = 'left', isPressed = false) { + await sendCdpCommand(page, 'Input.dispatchMouseEvent', { + type, + x, + y, + button: type === 'mouseMoved' && !isPressed ? 'none' : button, + buttons: isPressed ? getButtonMask(button) : 0, + pointerType: 'mouse', + clickCount: 1, + }); +} + +function interpolatePoints(sourcePoint, targetPoint, steps) { + const points = []; + for (let index = 1; index <= steps; index += 1) { + const ratio = index / steps; + points.push({ + x: Math.round(sourcePoint.x + (targetPoint.x - sourcePoint.x) * ratio), + y: Math.round(sourcePoint.y + (targetPoint.y - sourcePoint.y) * ratio), + }); + } + return points; +} + +async function resolveElementCenterPoint(page, selector, nth, frameSelector, pierceShadow, missingLabel) { + const state = await getScrolledElementStateAt(page, selector, nth, frameSelector, pierceShadow); + if (!state.exists) { + throw new Error(missingLabel); + } + if (state.connected !== true) { + throw new Error(`drag coordinates unavailable for selector: ${selector}`); + } + if (state.interactable !== true || !state.centerPoint) { + throw new Error(`drag coordinates unavailable for selector: ${selector}`); + } + return state.centerPoint; +} + +async function handlePointerAction(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const actions = Array.isArray(toolArgs.actions) ? toolArgs.actions : null; + if (!actions || actions.length === 0) { + throw new Error('actions must be a non-empty array'); + } + if (actions.length > MAX_POINTER_ACTIONS) { + throw new Error(`actions exceeds maximum of ${MAX_POINTER_ACTIONS}`); + } + + let pointerX = null; + let pointerY = null; + let pressedButton = null; + + for (const action of actions) { + if (!action || typeof action !== 'object') { + throw new Error('each action must be an object'); + } + if (!['move', 'down', 'up', 'pause'].includes(action.type)) { + throw new Error(`unsupported pointer action: ${String(action.type || '')}`); + } + + if (action.type === 'pause') { + const durationMs = action.durationMs === undefined ? 0 : requireNonNegativeInteger(action.durationMs, 'durationMs'); + if (durationMs > 0) { + await sleep(durationMs); + } + continue; + } + + if (action.type === 'move') { + let point = null; + if (typeof action.selector === 'string' && action.selector.trim() !== '') { + point = await resolveElementCenterPoint( + page, + requireNonEmptyString(action.selector, 'actions.selector'), + requireOptionalNonNegativeInteger(action.nth, 'actions.nth'), + parseOptionalNonEmptyString(action.frameSelector), + action.pierceShadow === true, + 'drag coordinates unavailable' + ); + } else if (typeof action.x === 'number' && typeof action.y === 'number') { + point = { + x: requireFiniteNumber(action.x, 'actions.x'), + y: requireFiniteNumber(action.y, 'actions.y'), + }; + } else { + throw new Error('drag coordinates unavailable'); + } + + pointerX = point.x; + pointerY = point.y; + await dispatchMousePointerEvent(page, 'mouseMoved', pointerX, pointerY, pressedButton || 'left', Boolean(pressedButton)); + continue; + } + + if (pointerX === null || pointerY === null) { + throw new Error('pointer state error'); + } + + if (action.type === 'down') { + if (pressedButton) { + throw new Error('pointer state error'); + } + pressedButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left'; + await dispatchMousePointerEvent(page, 'mousePressed', pointerX, pointerY, pressedButton, true); + continue; + } + + if (!pressedButton) { + throw new Error('pointer state error'); + } + const releaseButton = + requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; + if (releaseButton !== pressedButton) { + throw new Error('pointer state error'); + } + await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); + pressedButton = null; + } + + if (pressedButton) { + throw new Error('pointer state error'); + } + + return `pageIndex: ${pageIndex}\nactionCount: ${actions.length}\nstatus: pointer-actions-completed`; +} + +async function handleDragElement(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const targetNth = requireOptionalNonNegativeInteger(toolArgs.targetNth, 'targetNth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const targetSelector = parseOptionalNonEmptyString(toolArgs.targetSelector); + const hasTargetX = Object.prototype.hasOwnProperty.call(toolArgs, 'targetX'); + const hasTargetY = Object.prototype.hasOwnProperty.call(toolArgs, 'targetY'); + const hasTargetCoordinates = hasTargetX || hasTargetY; + if (targetSelector && hasTargetCoordinates) { + throw new Error('targetSelector and targetX/targetY cannot be used together'); + } + if (!targetSelector && !hasTargetCoordinates) { + throw new Error('targetSelector or targetX/targetY is required'); + } + if (hasTargetX !== hasTargetY) { + throw new Error('targetX and targetY must be provided together'); + } + + const sourcePoint = await resolveElementCenterPoint( + page, + selector, + nth, + frameSelector, + pierceShadow, + 'source element not found' + ); + + const targetPoint = targetSelector + ? await resolveElementCenterPoint( + page, + targetSelector, + targetNth, + frameSelector, + pierceShadow, + 'target element not found' + ) + : { + x: requireFiniteNumber(toolArgs.targetX, 'targetX'), + y: requireFiniteNumber(toolArgs.targetY, 'targetY'), + }; + + const steps = toolArgs.steps === undefined ? DEFAULT_DRAG_STEPS : requirePositiveInteger(toolArgs.steps, 'steps'); + + await dispatchMousePointerEvent(page, 'mouseMoved', sourcePoint.x, sourcePoint.y, 'left', false); + await dispatchMousePointerEvent(page, 'mousePressed', sourcePoint.x, sourcePoint.y, 'left', true); + for (const point of interpolatePoints(sourcePoint, targetPoint, steps)) { + await dispatchMousePointerEvent(page, 'mouseMoved', point.x, point.y, 'left', true); + } + await dispatchMousePointerEvent(page, 'mouseReleased', targetPoint.x, targetPoint.y, 'left', false); + + return [ + `pageIndex: ${pageIndex}`, + `selector: ${selector}`, + `nth: ${nth ?? 0}`, + targetSelector ? `targetSelector: ${targetSelector}` : `targetX: ${targetPoint.x}`, + targetSelector ? `targetNth: ${targetNth ?? 0}` : `targetY: ${targetPoint.y}`, + `steps: ${steps}`, + `${frameSelector ? `frameSelector: ${frameSelector}\n` : ''}${pierceShadow ? 'pierceShadow: true\n' : ''}status: dragged`, + ].join('\n'); +} + +async function handleHover(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const state = await getScrolledElementState(page, selector, frameSelector, pierceShadow); + if (!state.exists) { + throw new Error(`element not found for selector: ${selector}`); + } + if (state.connected !== true) { + throw new Error(`element is detached for selector: ${selector}`); + } + if (state.interactable !== true || !state.centerPoint) { + throw new Error(`element is hidden or not interactable for selector: ${selector}`); + } + + await dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false); + + return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`; +} + +async function handleQuery(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const fields = parseQueryFields(toolArgs.fields); + const diagnostics = await getElementDiagnostics(page, selector, nth, frameSelector, pierceShadow); + return formatQueryResponse(pageIndex, selector, nth, diagnostics, fields); +} + +async function handleElementScreenshot(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + const state = await getScrolledElementState(page, selector, frameSelector, pierceShadow); + if (!state.exists) { + throw new Error(`element not found for selector: ${selector}`); + } + if (state.connected !== true) { + throw new Error(`element is detached for selector: ${selector}`); + } + if (state.interactable !== true || !state.visibleClip) { + throw new Error(`element has empty bounds for selector: ${selector}`); + } + if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) { + throw new Error(`element has empty bounds for selector: ${selector}`); + } + + const response = await sendCdpCommand(page, 'Page.captureScreenshot', { + format: 'png', + clip: state.visibleClip, + }); + + const data = response && typeof response.data === 'string' ? response.data : ''; + if (!data) { + throw new Error('screenshot capture failed'); + } + + return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nformat: png\ndata: ${data}`; +} + +async function waitForPageEvent({ page, timeoutMs, event }) { + const ws = new WebSocket(page.webSocketDebuggerUrl); + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + abortSocket(ws); + reject(new Error(`event wait timed out for kind: ${event.kind}`)); + }, timeoutMs); + + const settleError = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + abortSocket(ws); + reject(toSocketError(error)); + }; + + const settleSuccess = (observed) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + closeSocket(ws); + resolve(observed); + }; + + addSocketListener(ws, 'open', () => { + if (event.kind === 'dialog' || event.kind === 'navigation') { + ws.send(JSON.stringify({ id: ++requestCounter, method: 'Page.enable', params: {} })); + } + if (event.kind === 'request') { + ws.send(JSON.stringify({ id: ++requestCounter, method: 'Network.enable', params: {} })); + } + }); + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (!message || typeof message !== 'object' || typeof message.method !== 'string') { + return; + } + let observed = null; + if (message.method === 'Page.javascriptDialogOpening') { + observed = { type: message.params?.type || '', message: message.params?.message || '' }; + } else if (message.method === 'Page.frameNavigated') { + const frame = message.params?.frame; + if (!frame?.parentId) { + observed = { url: frame?.url || '' }; + } + } else if (message.method === 'Network.requestWillBeSent') { + observed = { + url: message.params?.request?.url || '', + method: message.params?.request?.method || '', + }; + } + if (observed && matchesObservedEvent(event, observed)) { + settleSuccess(observed); + } + })().catch(settleError); + }); + + addSocketListener(ws, 'error', settleError); + addSocketListener(ws, 'close', () => { + if (!settled) { + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + } + }); + }); +} + +async function getPageFrameIds(page) { + const response = await sendCdpCommand(page, 'Page.getFrameTree', {}); + const frameTree = response?.frameTree; + const frameIds = new Set(); + + function visitFrameTree(node) { + if (!node || typeof node !== 'object') { + return; + } + const frameId = node.frame?.id; + if (typeof frameId === 'string' && frameId) { + frameIds.add(frameId); + } + const childFrames = Array.isArray(node.childFrames) ? node.childFrames : []; + for (const childFrame of childFrames) { + visitFrameTree(childFrame); + } + } + + visitFrameTree(frameTree); + + if (frameIds.size === 0) { + throw new Error('Browser MCP could not determine the selected page frame IDs.'); + } + return frameIds; +} + +async function waitForBrowserDownloadEvent(page, timeoutMs, event) { + const [targets, frameIds] = await Promise.all([ + fetchJson(`${getHttpUrl()}/json/list`), + getPageFrameIds(page), + ]); + const browserTarget = Array.isArray(targets) + ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') + : null; + if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + throw new Error('browser-level download events are unavailable'); + } + + const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + abortSocket(ws); + reject(new Error('event wait timed out for kind: download')); + }, timeoutMs); + + const settleError = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + abortSocket(ws); + reject(toSocketError(error)); + }; + + const settleSuccess = (observed) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + closeSocket(ws); + resolve(observed); + }; + + addSocketListener(ws, 'message', (data) => { + void (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (message?.method !== 'Browser.downloadWillBegin') { + return; + } + const eventFrameId = message.params?.frameId; + if (!frameIds.has(eventFrameId)) { + const refreshedFrameIds = await getPageFrameIds(page); + if (!refreshedFrameIds.has(eventFrameId)) { + return; + } + for (const frameId of refreshedFrameIds) { + frameIds.add(frameId); + } + } + const observed = { + url: message.params?.url || '', + suggestedFilename: message.params?.suggestedFilename || '', + }; + if (matchesObservedEvent(event, observed)) { + settleSuccess(observed); + } + })().catch(settleError); + }); + + addSocketListener(ws, 'error', settleError); + addSocketListener(ws, 'close', () => { + if (!settled) { + settleError(new Error('Browser MCP lost the DevTools websocket connection.')); + } + }); + }); +} + +async function waitForMatchingEvent({ page, timeoutMs, event }) { + if (event.kind === 'download') { + return await waitForBrowserDownloadEvent(page, timeoutMs, event); + } + return await waitForPageEvent({ page, timeoutMs, event }); +} + +async function handleWaitForEvent(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + const event = parseEventCondition(toolArgs.event); + const observed = await waitForMatchingEvent({ page, pageIndex, timeoutMs, event }); + return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`; +} + +function findInterceptRuleIndex(ruleId) { + return interceptRules.findIndex((rule) => rule.ruleId === ruleId); +} + +function getRulesForPage(pageId) { + return interceptRules.filter((rule) => rule.pageId === pageId); +} + +function getRulesForMatching(pageId) { + return getRulesForPage(pageId) + .slice() + .sort((left, right) => right.priority - left.priority); +} + +function matchesUrlPattern(pattern, url) { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`).test(url); +} + +function normalizePausedRequestHeaders(paused) { + const normalized = new Map(); + const headers = paused.request?.headers; + if (!headers || typeof headers !== 'object') { + return normalized; + } + for (const [name, value] of Object.entries(headers)) { + normalized.set(name.toLowerCase(), String(value)); + } + return normalized; +} + +function matchesHeaderMatchers(headerMatchers, paused) { + if (!headerMatchers.length) { + return true; + } + const normalizedHeaders = normalizePausedRequestHeaders(paused); + return headerMatchers.every((matcher) => { + const headerValue = normalizedHeaders.get(matcher.name.toLowerCase()); + if (headerValue === undefined) { + return false; + } + if (matcher.valueIncludes && !headerValue.includes(matcher.valueIncludes)) { + return false; + } + if (matcher.valueRegex && !new RegExp(matcher.valueRegex).test(headerValue)) { + return false; + } + return true; + }); +} + +function matchesInterceptRule(rule, paused) { + const requestMethod = String(paused.request?.method || '').toUpperCase(); + const requestUrl = String(paused.request?.url || ''); + const requestResourceType = String(paused.resourceType || ''); + if (rule.method && rule.method !== requestMethod) { + return false; + } + if (rule.urlIncludes && !requestUrl.includes(rule.urlIncludes)) { + return false; + } + if (rule.urlPattern && !matchesUrlPattern(rule.urlPattern, requestUrl)) { + return false; + } + if (rule.urlRegex && !new RegExp(rule.urlRegex).test(requestUrl)) { + return false; + } + if (rule.resourceType && rule.resourceType.toLowerCase() !== requestResourceType.toLowerCase()) { + return false; + } + if (!matchesHeaderMatchers(rule.headerMatchers || [], paused)) { + return false; + } + return true; +} + +async function ensureInterceptSession(page) { + const existing = interceptSessionsByPageId.get(page.id); + if (existing) { + return existing; + } + + const ws = new WebSocket(page.webSocketDebuggerUrl); + let enableRequestId = 0; + let resolveReady; + let rejectReady; + let activityChain = Promise.resolve(); + const pendingCommands = new Map(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const session = { + pageId: page.id, + webSocketDebuggerUrl: page.webSocketDebuggerUrl, + ws, + enabled: false, + ready, + pendingCommands, + activityVersion: 0, + getLastActivity() { + return activityChain; + }, + }; + interceptSessionsByPageId.set(page.id, session); + + function settleReadyError(error) { + if (rejectReady) { + rejectReady(error instanceof Error ? error : new Error(String(error))); + rejectReady = null; + resolveReady = null; + } + } + + function rejectPendingCommands(error) { + for (const pending of pendingCommands.values()) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + pendingCommands.clear(); + } + + addSocketListener(ws, 'open', () => { + enableRequestId = ++requestCounter; + ws.send( + JSON.stringify({ + id: enableRequestId, + method: 'Fetch.enable', + params: { patterns: [{ urlPattern: '*' }] }, + }) + ); + }); + + addSocketListener(ws, 'message', (data) => { + const activity = (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + session.activityVersion += 1; + if (message.id === enableRequestId) { + if (message.error) { + const error = new Error(message.error.message || 'DevTools request failed.'); + settleReadyError(error); + rejectPendingCommands(error); + removeInterceptStateForPage(page.id); + closeSocket(ws); + return; + } + session.enabled = true; + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + return; + } + if (message.id && pendingCommands.has(message.id)) { + const pending = pendingCommands.get(message.id); + pendingCommands.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'DevTools request failed.')); + return; + } + pending.resolve(message.result || null); + return; + } + if (message.method !== 'Fetch.requestPaused') { + return; + } + const paused = message.params || {}; + const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused)); + const action = matchedRule ? matchedRule.action : 'continue'; + if (action === 'fail') { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.failRequest', + params: { requestId: paused.requestId, errorReason: FETCH_FAIL_ERROR_REASON }, + }) + ); + } else if (action === 'fulfill') { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.fulfillRequest', + params: { + requestId: paused.requestId, + responseCode: matchedRule.statusCode, + responseHeaders: matchedRule.responseHeaders, + body: encodeFulfillBody(matchedRule.body), + }, + }) + ); + } else { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.continueRequest', + params: { requestId: paused.requestId }, + }) + ); + } + pushRecentRequest({ + requestId: String(paused.requestId || ''), + pageId: page.id, + url: String(paused.request?.url || ''), + method: String(paused.request?.method || ''), + resourceType: String(paused.resourceType || ''), + matchedRuleId: matchedRule ? matchedRule.ruleId : '', + action, + statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, + }); + })(); + activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + void activity.catch(() => {}); + }); + + addSocketListener(ws, 'close', () => { + const error = new Error('Browser MCP lost the DevTools websocket connection.'); + if (!session.enabled) { + settleReadyError(error); + } + rejectPendingCommands(error); + removeInterceptStateForPage(page.id); + }); + + addSocketListener(ws, 'error', (error) => { + const socketError = toSocketError(error); + if (!session.enabled) { + settleReadyError(socketError); + } + rejectPendingCommands(socketError); + removeInterceptStateForPage(page.id); + }); + + await ready; + return session; +} + +async function handleSelectPage(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + selectedPageId = page.id; + return `pageIndex: ${pageIndex}\npageId: ${page.id}\ntitle: ${page.title || ''}\nurl: ${page.url || ''}\nstatus: selected`; +} + +async function handleOpenPage(toolArgs) { + const query = toolArgs?.url + ? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}` + : ''; + const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`); + const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : ''; + if (!pageId) { + throw new Error('Browser MCP failed to create a new page target.'); + } + selectedPageId = pageId; + const pages = await listPageTargets(); + const pageIndex = findPageIndexById(pages, pageId); + const selectedPage = pages[pageIndex]; + if (!selectedPage) { + throw new Error('Browser MCP could not resolve the newly opened page target.'); + } + return `pageIndex: ${pageIndex}\npageId: ${selectedPage.id}\ntitle: ${selectedPage.title || ''}\nurl: ${selectedPage.url || ''}\nstatus: opened`; +} + +async function handleClosePage(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + const previousSelectedPageId = selectedPageId; + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (activeRecordingSession && activeRecordingSession.pageId === page.id) { + try { + await finalizeRecordingCapture(activeRecordingSession); + } catch { + // Keep the recording session and still report the page-close warning below. + } + activeRecordingSession.status = 'stopped'; + activeRecordingSession.stoppedAt = new Date().toISOString(); + activeRecordingSession.warnings.push('recording stopped because target page was closed'); + latestRecordingSession = activeRecordingSession; + activeRecordingSession = null; + } + + await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); + const interceptSession = interceptSessionsByPageId.get(page.id); + if (interceptSession) { + closeSocket(interceptSession.ws); + } + removeInterceptStateForPage(page.id); + const remainingPages = await listPageTargets(); + if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) { + selectedPageId = previousSelectedPageId; + } else { + selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0); + } + const selectedIndex = findPageIndexById(remainingPages, selectedPageId); + return [ + `pageIndex: ${pageIndex}`, + `pageId: ${page.id}`, + 'status: closed', + selectedPageId ? `selectedPageIndex: ${selectedIndex}` : 'selectedPageIndex: none', + selectedPageId ? `selectedPageId: ${selectedPageId}` : 'selectedPageId: none', + ].join('\n'); +} + +async function handleAddInterceptRule(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + const defaultPageId = selectedPageId || resolveFallbackSelectedPageId(pages, 0); + const { page } = resolveTargetPage(pages, toolArgs, defaultPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error('Target page does not expose a websocket debugger URL.'); + } + const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes); + const method = parseOptionalMethod(toolArgs.method); + const resourceType = parseOptionalResourceType(toolArgs.resourceType); + const urlPattern = parseOptionalUrlPattern(toolArgs.urlPattern); + const urlRegex = parseOptionalUrlRegex(toolArgs.urlRegex); + const headerMatchers = parseOptionalHeaderMatchers(toolArgs.headerMatchers); + const priority = parseOptionalPriority(toolArgs.priority); + const action = parseInterceptAction(toolArgs.action); + validateInterceptMatcherSet({ + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, + }); + const statusCode = parseOptionalStatusCode(toolArgs.statusCode); + const contentType = + toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); + const body = parseOptionalBody(toolArgs.body); + const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType); + const rule = { + ruleId: createInterceptRuleId(), + pageId: page.id, + pageTitleSnapshot: page.title || '', + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, + priority, + action, + statusCode: action === 'fulfill' ? statusCode : 0, + contentType: action === 'fulfill' ? contentType : '', + responseHeaders: action === 'fulfill' ? responseHeaders : [], + body: action === 'fulfill' ? body : '', + createdAt: new Date().toISOString(), + }; + interceptRules.push(rule); + await ensureInterceptSession(page); + return formatInterceptRules([rule]); +} + +async function handleRemoveInterceptRule(toolArgs) { + const ruleId = requireNonEmptyString(toolArgs.ruleId, 'ruleId'); + const index = findInterceptRuleIndex(ruleId); + if (index === -1) { + throw new Error(`rule not found: ${ruleId}`); + } + const [removed] = interceptRules.splice(index, 1); + if (getRulesForPage(removed.pageId).length === 0) { + const session = interceptSessionsByPageId.get(removed.pageId); + if (session) { + closeSocket(session.ws); + interceptSessionsByPageId.delete(removed.pageId); + } + } + return `ruleId: ${removed.ruleId}\nstatus: removed`; +} + +async function handleListInterceptRules() { + const pages = await listPageTargets(); + const livePageIds = new Set(pages.map((page) => page.id)); + for (let index = interceptRules.length - 1; index >= 0; index -= 1) { + if (!livePageIds.has(interceptRules[index].pageId)) { + interceptRules.splice(index, 1); + } + } + return formatInterceptRules(interceptRules); +} + +async function handleListRequests(toolArgs) { + const pages = await listPageTargets(); + const filteredPage = + toolArgs && (Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex') || toolArgs.pageId) + ? resolveTargetPage(pages, toolArgs, selectedPageId) + : null; + const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20); + const sessions = Array.from(interceptSessionsByPageId.values()); + await Promise.all(sessions.map((session) => session.ready)); + await Promise.all( + sessions.map(async (session) => { + const barrierRequestId = ++requestCounter; + const barrierPromise = new Promise((resolve, reject) => { + session.pendingCommands.set(barrierRequestId, { resolve, reject }); + }); + session.ws.send( + JSON.stringify({ + id: barrierRequestId, + method: 'Runtime.evaluate', + params: { + expression: '0', + returnByValue: true, + }, + }) + ); + await barrierPromise; + await session.getLastActivity(); + await new Promise((resolve) => setTimeout(resolve, 35)); + await session.getLastActivity(); + }) + ); + const entries = recentRequests + .filter((entry) => !filteredPage || entry.pageId === filteredPage.page.id) + .slice(-limit) + .reverse(); + return formatRecentRequests(entries); +} + +async function handleSetDownloadBehavior(toolArgs) { + const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['accept', 'deny']); + const downloadPath = parseOptionalNonEmptyString(toolArgs.downloadPath); + const eventsEnabled = toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true; + + if (behavior === 'deny' && downloadPath) { + throw new Error('downloadPath is only allowed when behavior=accept'); + } + + await ensureBrowserDownloadSession(); + + const resolvedDownloadPath = + behavior === 'accept' ? ensureWritableDirectory(downloadPath || getSessionDownloadPath()) : ''; + await sendBrowserCdpCommand('Browser.setDownloadBehavior', { + behavior: behavior === 'accept' ? 'allow' : 'deny', + ...(resolvedDownloadPath ? { downloadPath: resolvedDownloadPath } : {}), + eventsEnabled, + }); + + return `scope: browser\nbehavior: ${behavior}\ndownloadPath: ${resolvedDownloadPath || ''}\neventsEnabled: ${eventsEnabled}\nstatus: configured`; +} + +async function handleListDownloads(toolArgs) { + const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20); + if (browserDownloadSession) { + await browserDownloadSession.ready; + await browserDownloadSession.getLastActivity(); + await sleep(35); + await browserDownloadSession.getLastActivity(); + } + const entries = recentDownloads.slice(-limit).reverse(); + return formatRecentDownloads(entries); +} + +function resolveDownloadRecord(toolArgs) { + const downloadId = parseOptionalNonEmptyString(toolArgs.downloadId); + const guid = parseOptionalNonEmptyString(toolArgs.guid); + if (!downloadId && !guid) { + throw new Error('downloadId or guid is required'); + } + + const matched = recentDownloads.filter( + (entry) => (!downloadId || entry.downloadId === downloadId) && (!guid || entry.guid === guid) + ); + if (matched.length !== 1) { + throw new Error('download not found'); + } + return matched[0]; +} + +async function handleCancelDownload(toolArgs) { + const entry = resolveDownloadRecord(toolArgs); + if (entry.status === 'completed' || entry.status === 'failed' || entry.status === 'denied') { + throw new Error(`download is not cancelable in status: ${entry.status}`); + } + + await sendBrowserCdpCommand('Browser.cancelDownload', { guid: entry.guid }); + entry.status = 'canceled'; + entry.finishedAt = new Date().toISOString(); + return `downloadId: ${entry.downloadId}\nguid: ${entry.guid}\nstatus: canceled`; +} + +async function handleSetFileInput(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files')); + const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + + await withPageCommandSession(page, async (session) => { + const objectId = await evaluateObjectHandle( + session, + buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow) + ); + await session.sendCommand('DOM.setFileInputFiles', { + files, + objectId, + }); + }); + + return formatFileInputResult({ + pageIndex, + selector, + nth, + frameSelector, + pierceShadow, + files, + }); +} + +async function handleDragFiles(toolArgs) { + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const files = readLocalFilesForDrop(requireNonEmptyStringArray(toolArgs.files, 'files')); + const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth'); + const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); + const pierceShadow = toolArgs.pierceShadow === true; + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, files), + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'drop target evaluation failed'); + } + + const result = response?.result || null; + if (!result) { + throw new Error('Browser MCP received an invalid DevTools evaluation response.'); + } + if (result.subtype === 'error') { + throw new Error(result.description || 'drop target evaluation failed'); + } + if (result.value?.accepted === false) { + throw new Error('drop target rejected files'); + } + + return formatDragFilesResult({ + pageIndex, + selector, + nth, + frameSelector, + pierceShadow, + files, + }); +} + +function buildRecordingInstallExpression(recordingPayload) { + return `(() => { + const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))}); + const existing = globalThis.__CCS_BROWSER_RECORDING_RECORDER__; + if (existing && existing.installed === true) { + return { installed: true }; + } + + const events = Array.isArray(recordingPayload.events) ? [...recordingPayload.events] : []; + const warnings = Array.isArray(recordingPayload.warnings) ? [...recordingPayload.warnings] : []; + const getSelector = (element) => { + if (!(element instanceof Element)) { + return undefined; + } + if (element.id) { + return '#' + element.id; + } + const attr = typeof element.getAttribute === 'function' ? element.getAttribute('data-testid') : ''; + if (attr) { + return '[data-testid="' + attr + '"]'; + } + return element.tagName ? element.tagName.toLowerCase() : undefined; + }; + const pushEvent = (event) => { + events.push(event); + }; + const onClick = (event) => { + pushEvent({ + kind: 'click', + selector: getSelector(event.target), + button: event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left', + clickCount: event.detail || 1, + offsetX: typeof event.offsetX === 'number' ? event.offsetX : undefined, + offsetY: typeof event.offsetY === 'number' ? event.offsetY : undefined, + timestamp: Date.now(), + }); + }; + const onInput = (event) => { + const target = event.target; + let text = ''; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + text = target.value; + } else if (target && target.isContentEditable === true) { + text = target.textContent || ''; + } else { + return; + } + pushEvent({ kind: 'type', selector: getSelector(target), text, timestamp: Date.now() }); + }; + const onKeyDown = (event) => { + const modifiers = []; + if (event.altKey) modifiers.push('Alt'); + if (event.ctrlKey) modifiers.push('Control'); + if (event.metaKey) modifiers.push('Meta'); + if (event.shiftKey) modifiers.push('Shift'); + pushEvent({ kind: 'press_key', key: event.key, modifiers, timestamp: Date.now() }); + }; + const onScroll = (event) => { + const target = event.target === document ? document.scrollingElement || document.documentElement : event.target; + pushEvent({ + kind: 'scroll', + selector: getSelector(target), + deltaX: 0, + deltaY: 0, + timestamp: Date.now(), + }); + }; + + document.addEventListener('click', onClick, true); + document.addEventListener('input', onInput, true); + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('scroll', onScroll, true); + + globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = { + installed: true, + events, + warnings, + teardown: () => { + document.removeEventListener('click', onClick, true); + document.removeEventListener('input', onInput, true); + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('scroll', onScroll, true); + }, + }; + return { installed: true }; + })()`; +} + +async function installRecorderAndCapture(page) { + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: buildRecordingInstallExpression({ events: [], warnings: [] }), + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'recording injection failed'); + } + + const result = response?.result || null; + if (!result) { + throw new Error('recording injection failed'); + } + if (result.subtype === 'error') { + throw new Error(result.description || 'recording injection failed'); + } + + return result.value || { installed: true }; +} + +async function finalizeRecordingCapture(session) { + const page = { + id: session.pageId, + webSocketDebuggerUrl: session.pageWebSocketDebuggerUrl, + }; + + const response = await sendCdpCommand(page, 'Runtime.evaluate', { + expression: `(() => globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] })()`, + returnByValue: true, + awaitPromise: true, + }); + + if (response?.exceptionDetails) { + throw new Error(response.exceptionDetails.text || 'recording injection failed'); + } + + const value = response?.result?.value || { events: [], warnings: [] }; + const rawEvents = Array.isArray(value.events) ? value.events : []; + const warnings = Array.isArray(value.warnings) ? value.warnings : []; + + session.rawEvents = rawEvents; + session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean); + session.warnings = warnings.map((warning) => String(warning.message || warning)); +} + +async function handleStartRecording(toolArgs) { + if (activeRecordingSession) { + throw new Error('recording already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const session = { + recordingId: createRecordingId(), + pageId: page.id, + pageIndex, + startedAt: new Date().toISOString(), + stoppedAt: null, + status: 'recording', + steps: [], + warnings: [], + rawEvents: [], + pageWebSocketDebuggerUrl: page.webSocketDebuggerUrl, + captureInstalled: false, + }; + + await installRecorderAndCapture(page); + session.captureInstalled = true; + activeRecordingSession = session; + latestRecordingSession = session; + + return formatRecordingSummary(session); +} + +async function handleStopRecording() { + if (!activeRecordingSession) { + throw new Error('no active recording'); + } + const session = activeRecordingSession; + let finalizeError = null; + try { + await finalizeRecordingCapture(session); + } catch (error) { + finalizeError = error instanceof Error ? error : new Error(String(error)); + session.warnings.push( + `recording capture finalization failed: ${finalizeError.message}` + ); + } + session.status = 'stopped'; + session.stoppedAt = new Date().toISOString(); + activeRecordingSession = null; + latestRecordingSession = session; + if (finalizeError) { + throw finalizeError; + } + return formatRecordingSummary(session); +} + +async function handleGetRecording() { + const session = requireLatestRecording(); + return formatRecordingDetail(session); +} + +async function handleClearRecording() { + if (!latestRecordingSession && !activeRecordingSession) { + throw new Error('no recording available'); + } + activeRecordingSession = null; + latestRecordingSession = null; + return 'status: cleared'; +} + +function buildReplayToolArgs(step, replayPage) { + const baseArgs = { + pageId: replayPage.pageId, + }; + + if (step.type === 'click') { + return { + toolName: TOOL_CLICK, + toolArgs: { + ...baseArgs, + selector: requireNonEmptyString(step.selector, 'selector'), + nth: step.nth, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + button: step.args?.button, + clickCount: step.args?.clickCount, + offsetX: step.args?.offsetX, + offsetY: step.args?.offsetY, + }, + }; + } + + if (step.type === 'type') { + return { + toolName: TOOL_TYPE, + toolArgs: { + ...baseArgs, + selector: requireNonEmptyString(step.selector, 'selector'), + nth: step.nth, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + text: requireString(step.args?.text, 'text'), + clearFirst: true, + }, + }; + } + + if (step.type === 'press_key') { + return { + toolName: TOOL_PRESS_KEY, + toolArgs: { + ...baseArgs, + key: requireNonEmptyString(step.args?.key, 'key'), + modifiers: Array.isArray(step.args?.modifiers) ? step.args.modifiers : [], + }, + }; + } + + if (step.type === 'scroll') { + return { + toolName: TOOL_SCROLL, + toolArgs: { + ...baseArgs, + selector: step.selector, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + behavior: 'by-offset', + deltaX: step.args?.deltaX ?? 0, + deltaY: step.args?.deltaY ?? 0, + }, + }; + } + + if (step.type === 'drag_element') { + return { + toolName: TOOL_DRAG_ELEMENT, + toolArgs: { + ...baseArgs, + selector: requireNonEmptyString(step.selector, 'selector'), + nth: step.nth, + frameSelector: step.frameSelector, + pierceShadow: step.pierceShadow, + ...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}), + ...(step.args?.targetNth !== undefined ? { targetNth: step.args.targetNth } : {}), + ...(step.args?.targetX !== undefined ? { targetX: step.args.targetX } : {}), + ...(step.args?.targetY !== undefined ? { targetY: step.args.targetY } : {}), + }, + }; + } + + if (step.type === 'pointer_action') { + return { + toolName: TOOL_POINTER_ACTION, + toolArgs: { + ...baseArgs, + actions: Array.isArray(step.args?.actions) ? step.args.actions : [], + }, + }; + } + + return null; +} + +async function executeReplaySteps(session) { + for (let index = 0; index < session.steps.length; index += 1) { + throwIfSessionCanceled(session, 'replay'); + const step = session.steps[index]; + session.currentStepIndex = index; + validateReplayStep(step, session.pageId); + + const mapped = buildReplayToolArgs(step, session); + if (!mapped) { + throw new Error('unsupported replay step type'); + } + + if (mapped.toolName === TOOL_CLICK) { + await handleClick(mapped.toolArgs); + } else if (mapped.toolName === TOOL_TYPE) { + await handleType(mapped.toolArgs); + } else if (mapped.toolName === TOOL_PRESS_KEY) { + await handlePressKey(mapped.toolArgs); + } else if (mapped.toolName === TOOL_SCROLL) { + await handleScroll(mapped.toolArgs); + } else if (mapped.toolName === TOOL_DRAG_ELEMENT) { + await handleDragElement(mapped.toolArgs); + } else if (mapped.toolName === TOOL_POINTER_ACTION) { + await handlePointerAction(mapped.toolArgs); + } else { + throw new Error('unsupported replay step type'); + } + + session.completedSteps = index + 1; + throwIfSessionCanceled(session, 'replay'); + } +} + +async function runReplaySession(session, options = {}) { + try { + await executeReplaySteps(session); + if (session.cancelRequested) { + session.status = 'canceled'; + } else { + session.status = 'completed'; + } + } catch (error) { + if (isSessionCanceledError(error) || session.cancelRequested) { + session.status = 'canceled'; + session.error = null; + } else { + session.status = 'failed'; + session.failedStepIndex = session.currentStepIndex; + session.error = error instanceof Error ? error.message : String(error); + } + } finally { + latestReplaySession = session; + if (options.manageActiveState === true) { + if (activeReplaySession === session) { + activeReplaySession = null; + } + activeReplayTask = null; + } + } + return session; +} + +async function handleStartReplay(toolArgs) { + if (activeReplaySession) { + throw new Error('replay already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const steps = requireReplaySteps(toolArgs); + const session = createReplaySession(page, pageIndex, steps); + + latestReplaySession = session; + activeReplaySession = session; + const task = Promise.resolve().then(() => + runReplaySession(session, { manageActiveState: true }) + ); + activeReplayTask = task; + task.catch(() => { + // Session state is recorded in runReplaySession. + }); + await waitForSessionToSettle(task); + return formatReplaySummary(session); +} + +async function handleGetReplay() { + const session = requireLatestReplay(); + return formatReplaySummary(session); +} + +async function handleCancelReplay() { + if (!activeReplaySession) { + throw new Error('no active replay'); + } + activeReplaySession.cancelRequested = true; + activeReplaySession.status = 'canceled'; + latestReplaySession = activeReplaySession; + return formatReplaySummary(activeReplaySession); +} + +async function executeOrchestrationBlock(session, block) { + if (block.type === 'select_page_then_run') { + const selectResult = await handleSelectPage(block.args.select || {}); + const match = /pageId: (.+)/.exec(selectResult); + const selectedPageId = match?.[1]?.trim() || session.pageId; + const runBlock = requireCrossPageRunBlock(block); + const childSession = { ...session, pageId: selectedPageId }; + await executeOrchestrationBlock(childSession, runBlock); + return; + } + + if (block.type === 'open_page_then_run') { + const openResult = await handleOpenPage(block.args.open || {}); + const match = /pageId: (.+)/.exec(openResult); + const openedPageId = match?.[1]?.trim() || session.pageId; + const runBlock = requireCrossPageRunBlock(block); + const childSession = { ...session, pageId: openedPageId }; + await executeOrchestrationBlock(childSession, runBlock); + return; + } + + if (block.type === 'close_page_then_continue') { + await handleClosePage(block.args.close || {}); + return; + } + + if (block.type === 'wait_for_then_click') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handleClick({ pageId: session.pageId, ...block.args.click }); + return; + } + + if (block.type === 'wait_for_then_type') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handleType({ pageId: session.pageId, ...block.args.type }); + return; + } + + if (block.type === 'wait_for_then_press_key') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handlePressKey({ pageId: session.pageId, ...block.args.pressKey }); + return; + } + + if (block.type === 'run_replay_sequence') { + const replaySteps = requireReplaySteps({ steps: block.args.steps }); + const replaySession = createReplaySession( + { id: session.pageId }, + session.pageIndex, + replaySteps + ); + await runReplaySession(replaySession); + if (replaySession.status === 'failed' || replaySession.status === 'canceled') { + throw new Error(formatReplaySummary(replaySession)); + } + return; + } + + if (block.type === 'assert_query') { + const queryText = await handleQuery({ pageId: session.pageId, ...block.args.query }); + const queryMap = parseQueryTextToMap(queryText); + const assertions = parseAssertions(block.args); + + for (const [index, assertion] of assertions.entries()) { + const actualValue = queryMap[assertion.field]; + if (actualValue === undefined) { + throw new Error(`assert_query field missing from query result: ${assertion.field}`); + } + const failure = evaluateAssertion(assertion, actualValue, index); + if (failure) { + throw new Error(JSON.stringify(failure)); + } + } + return; + } + + if (block.type === 'sequence') { + await executeSequenceSteps(session, block); + return; + } + + throw new Error('unsupported orchestration block type'); +} + +async function executeSequenceSteps(session, block) { + const steps = requireSequenceSteps(block); + session.failedSequenceStepIndex = null; + for (let index = 0; index < steps.length; index += 1) { + throwIfSessionCanceled(session, 'orchestration'); + const step = steps[index]; + validateSequenceStep(step); + try { + await executeOrchestrationBlock(session, step); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pushOrchestrationFailure(session, { + blockIndex: session.currentBlockIndex, + sequenceStepIndex: index, + type: step.type, + message, + }); + session.failedSequenceStepIndex = index; + if (step.continueOnError === true) { + continue; + } + throw markOrchestrationFailureRecorded(error); + } + } +} + +async function executeOrchestrationBlocks(session) { + for (let index = 0; index < session.blocks.length; index += 1) { + throwIfSessionCanceled(session, 'orchestration'); + const block = session.blocks[index]; + session.currentBlockIndex = index; + session.failedSequenceStepIndex = null; + validateOrchestrationBlock(block); + try { + await executeOrchestrationBlock(session, block); + session.completedBlocks = index + 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!wasOrchestrationFailureRecorded(error)) { + pushOrchestrationFailure(session, { + blockIndex: index, + sequenceStepIndex: null, + type: block.type, + message, + }); + } + if (block.continueOnError === true) { + session.completedBlocks = index + 1; + continue; + } + throw error; + } + } +} + +async function runOrchestrationSession(session, options = {}) { + try { + await executeOrchestrationBlocks(session); + if (session.cancelRequested) { + session.status = 'canceled'; + } else if ((session.failedCount || 0) > 0) { + session.status = 'completed_with_failures'; + } else { + session.status = 'completed'; + } + } catch (error) { + if (isSessionCanceledError(error) || session.cancelRequested) { + session.status = 'canceled'; + session.error = null; + session.errorDetails = undefined; + } else { + session.status = 'failed'; + session.failedBlockIndex = session.currentBlockIndex; + session.errorDetails = formatOrchestrationError(error); + session.error = + session.errorDetails.message || (error instanceof Error ? error.message : String(error)); + } + } finally { + latestOrchestrationSession = session; + if (options.manageActiveState === true) { + if (activeOrchestrationSession === session) { + activeOrchestrationSession = null; + } + activeOrchestrationTask = null; + } + } + return session; +} + +async function handleStartOrchestration(toolArgs) { + if (activeOrchestrationSession) { + throw new Error('orchestration already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const blocks = requireOrchestrationBlocks(toolArgs); + const session = createOrchestrationSession(page, pageIndex, blocks); + + latestOrchestrationSession = session; + activeOrchestrationSession = session; + const task = Promise.resolve().then(() => + runOrchestrationSession(session, { manageActiveState: true }) + ); + activeOrchestrationTask = task; + task.catch(() => { + // Session state is recorded in runOrchestrationSession. + }); + await waitForSessionToSettle(task); + return formatOrchestrationSummary(session); +} + +async function handleGetOrchestration() { + const session = requireLatestOrchestration(); + return formatOrchestrationSummary(session); +} + +async function handleCancelOrchestration() { + if (!activeOrchestrationSession) { + throw new Error('no active orchestration'); + } + activeOrchestrationSession.cancelRequested = true; + activeOrchestrationSession.status = 'canceled'; + latestOrchestrationSession = activeOrchestrationSession; + return formatOrchestrationSummary(activeOrchestrationSession); +} + +function getArtifactPayloadForKind(kind) { + if (kind === 'recording') { + return requireLatestRecording(); + } + if (kind === 'replay') { + return requireLatestReplay(); + } + if (kind === 'orchestration') { + return requireLatestOrchestration(); + } + throw new Error(`unsupported artifact kind: ${kind}`); +} + +function resolveArtifactPath(reference) { + const value = requireNonEmptyString(reference, 'path'); + if (value.startsWith('artifact:')) { + return getArtifactPath(value.slice('artifact:'.length)); + } + return path.resolve(value); +} + +function deleteArtifactByName(name) { + const filePath = getArtifactPath(name); + if (!fs.existsSync(filePath)) { + throw new Error('artifact not found'); + } + fs.unlinkSync(filePath); + return filePath; +} + +async function handleExportArtifact(toolArgs) { + const kind = requireNonEmptyString(toolArgs.kind, 'kind'); + const name = requireArtifactName(toolArgs.name); + const filePath = getArtifactPath(name); + if (fs.existsSync(filePath)) { + throw new Error('artifact already exists'); + } + const payload = getArtifactPayloadForKind(kind); + const artifact = buildArtifact(kind, name, payload); + const serialized = JSON.stringify(artifact, null, 2); + if (Buffer.byteLength(serialized, 'utf8') > MAX_ARTIFACT_FILE_BYTES) { + throw new Error( + `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` + ); + } + fs.writeFileSync(filePath, serialized, { encoding: 'utf8', mode: 0o600 }); + return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`; +} + +async function handleListArtifacts() { + const dir = getArtifactDir(); + const entries = fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.json')) + .map((entry) => readArtifactFile(path.join(dir, entry))); + if (entries.length === 0) { + return 'artifacts: []'; + } + return entries + .map((entry) => [ + `name: ${entry.name}`, + `kind: ${entry.kind}`, + `createdAt: ${entry.createdAt}`, + `path: ${getArtifactPath(entry.name)}`, + ].join('\n')) + .join('\n---\n'); +} + +async function handleImportArtifact(toolArgs) { + const filePath = resolveArtifactPath(toolArgs.path); + const artifact = readArtifactFile(filePath); + + if (artifact.kind === 'recording') { + latestRecordingSession = artifact.payload; + } else if (artifact.kind === 'replay') { + latestReplaySession = artifact.payload; + } else if (artifact.kind === 'orchestration') { + latestOrchestrationSession = artifact.payload; + } else { + throw new Error('artifact kind mismatch'); + } + + return `name: ${artifact.name}\nkind: ${artifact.kind}\nstatus: imported`; +} + +async function handleDeleteArtifact(toolArgs) { + const name = requireArtifactName(toolArgs.name); + deleteArtifactByName(name); + return `name: ${name}\nstatus: deleted`; +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; const toolName = params.name || ''; const toolArgs = params.arguments || {}; - if (!TOOL_NAMES.includes(toolName)) { + if (!getAvailableToolNames().includes(toolName)) { writeError(id, -32602, `Unknown tool: ${toolName}`); return; } @@ -785,8 +5795,11 @@ async function handleToolCall(message) { try { if (toolName === TOOL_SESSION_INFO) { const pages = await listPageTargets(); + if (pages.length > 0 && findPageIndexById(pages, selectedPageId) === -1) { + selectedPageId = resolveFallbackSelectedPageId(pages, 0); + } writeResponse(id, { - content: [{ type: 'text', text: formatSessionInfo(pages) }], + content: [{ type: 'text', text: formatSessionInfo(pages, selectedPageId) }], }); return; } @@ -815,6 +5828,246 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_PRESS_KEY) { + const text = await handlePressKey(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SCROLL) { + const text = await handleScroll(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SELECT_PAGE) { + const text = await handleSelectPage(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_OPEN_PAGE) { + const text = await handleOpenPage(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLOSE_PAGE) { + const text = await handleClosePage(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_ADD_INTERCEPT_RULE) { + const text = await handleAddInterceptRule(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_REMOVE_INTERCEPT_RULE) { + const text = await handleRemoveInterceptRule(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_INTERCEPT_RULES) { + const text = await handleListInterceptRules(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_REQUESTS) { + const text = await handleListRequests(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SET_DOWNLOAD_BEHAVIOR) { + const text = await handleSetDownloadBehavior(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_DOWNLOADS) { + const text = await handleListDownloads(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_DOWNLOAD) { + const text = await handleCancelDownload(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_SET_FILE_INPUT) { + const text = await handleSetFileInput(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DRAG_FILES) { + const text = await handleDragFiles(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DRAG_ELEMENT) { + const text = await handleDragElement(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_POINTER_ACTION) { + const text = await handlePointerAction(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_START_RECORDING) { + const text = await handleStartRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_STOP_RECORDING) { + const text = await handleStopRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_RECORDING) { + const text = await handleGetRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CLEAR_RECORDING) { + const text = await handleClearRecording(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_START_REPLAY) { + const text = await handleStartReplay(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_REPLAY) { + const text = await handleGetReplay(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_REPLAY) { + const text = await handleCancelReplay(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_START_ORCHESTRATION) { + const text = await handleStartOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_ORCHESTRATION) { + const text = await handleGetOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_ORCHESTRATION) { + const text = await handleCancelOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_EXPORT_ARTIFACT) { + const text = await handleExportArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_IMPORT_ARTIFACT) { + const text = await handleImportArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_ARTIFACTS) { + const text = await handleListArtifacts(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DELETE_ARTIFACT) { + const text = await handleDeleteArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + if (toolName === TOOL_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { @@ -823,6 +6076,54 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_WAIT_FOR) { + const text = await handleWaitFor(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_EVAL) { + const text = await handleEval(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_HOVER) { + const text = await handleHover(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_QUERY) { + const text = await handleQuery(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_TAKE_ELEMENT_SCREENSHOT) { + const text = await handleElementScreenshot(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_WAIT_FOR_EVENT) { + const text = await handleWaitForEvent(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + const { page, pageIndex } = await getSelectedPage(toolArgs); if (toolName === TOOL_URL_TITLE) { @@ -883,7 +6184,7 @@ async function handleMessage(message) { writeResponse(message.id, {}); return; case 'tools/list': - writeResponse(message.id, { tools: getTools() }); + writeResponse(message.id, { tools: getAvailableTools() }); return; case 'tools/call': await handleToolCall(message); @@ -945,11 +6246,13 @@ function parseMessages() { continue; } - Promise.resolve(handleMessage(message)).catch((error) => { - if (message && message.id !== undefined) { - writeError(message.id, -32603, (error && error.message) || 'Internal error'); - } - }); + messageQueue = messageQueue + .then(() => handleMessage(message)) + .catch((error) => { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } + }); } } diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index 36b66b28..b325a3d8 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -45,7 +45,9 @@ echo "[i] Pre-push CI parity gate" echo " branch: $CURRENT_BRANCH" echo " base: $BASE_BRANCH" -git fetch origin "$BASE_BRANCH" --quiet || true +if git ls-remote --exit-code --heads origin "$BASE_BRANCH" >/dev/null 2>&1; then + git fetch origin "$BASE_BRANCH" --quiet +fi if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then if ! git merge-base --is-ancestor "origin/$BASE_BRANCH" HEAD; then echo "[X] Branch '$CURRENT_BRANCH' is behind origin/$BASE_BRANCH." diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index adca8e60..6d97a1ee 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -22,7 +22,12 @@ const slowTests = [ 'tests/integration/cursor-daemon-lifecycle.test.ts', 'tests/integration/proxy/daemon-lifecycle.test.ts', 'tests/unit/commands/persist-command-handler.test.ts', - 'tests/unit/hooks/ccs-browser-mcp-server.test.ts', + 'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts', + 'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts', + 'tests/unit/hooks/browser-mcp-navigation-and-query.test.ts', + 'tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts', + 'tests/unit/hooks/browser-mcp-recording-and-replay.test.ts', + 'tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts', 'tests/unit/targets/codex-runtime-integration.test.ts', 'tests/unit/targets/codex-settings-bridge-launch.test.ts', 'tests/unit/targets/droid-command-routing-integration.test.ts', diff --git a/src/ccs.ts b/src/ccs.ts index a908fce3..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'; @@ -1329,6 +1329,10 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: '', + CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED: '0', }; } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { const ensureServiceResult = await ensureCliproxyService( @@ -1363,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, @@ -1375,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 bafcea63..b5872947 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -28,7 +28,7 @@ import { import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; -import { stripClaudeCodeEnv } from '../../utils/shell-executor'; +import { stripBrowserEnv, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; @@ -383,12 +383,17 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record v !== undefined) + const baseEnv = stripBrowserEnv( + Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record< + string, + string + > ) 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/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 123f994d..261b2d72 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -37,6 +37,7 @@ import type { OfficialChannelId, DashboardAuthConfig, BrowserConfig, + BrowserEvalMode, BrowserToolPolicy, ImageAnalysisConfig, LoggingConfig, @@ -76,17 +77,37 @@ function normalizeBrowserPolicy(value: string | undefined): BrowserToolPolicy { return value === 'auto' || value === 'manual' ? value : DEFAULT_BROWSER_CONFIG.claude.policy; } -function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { +function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { + if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { + return value; + } + + return DEFAULT_BROWSER_CONFIG.claude.eval_mode ?? 'readonly'; +} + +function canonicalizeBrowserConfig( + config?: BrowserConfig, + fallback: BrowserConfig = DEFAULT_BROWSER_CONFIG +): BrowserConfig { + const claudeUserDataDir = + config?.claude?.user_data_dir === undefined + ? fallback.claude.user_data_dir || getRecommendedBrowserUserDataDir() + : config.claude.user_data_dir.trim() || getRecommendedBrowserUserDataDir(); + return { claude: { - enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, - policy: normalizeBrowserPolicy(config?.claude?.policy), - user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), - devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + enabled: config?.claude?.enabled ?? fallback.claude.enabled, + policy: normalizeBrowserPolicy(config?.claude?.policy ?? fallback.claude.policy), + user_data_dir: claudeUserDataDir, + devtools_port: normalizeBrowserDevtoolsPort( + config?.claude?.devtools_port ?? fallback.claude.devtools_port + ), + eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode ?? fallback.claude.eval_mode), }, codex: { - enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, - policy: normalizeBrowserPolicy(config?.codex?.policy), + enabled: config?.codex?.enabled ?? fallback.codex.enabled, + policy: normalizeBrowserPolicy(config?.codex?.policy ?? fallback.codex.policy), + eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode ?? fallback.codex.eval_mode), }, }; } @@ -1133,7 +1154,13 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { return withConfigWriteLock(() => { const current = loadUnifiedConfigWithLockHeld(); + const previousBrowser = current.browser + ? canonicalizeBrowserConfig(current.browser) + : undefined; mutator(current); + if (current.browser) { + current.browser = canonicalizeBrowserConfig(current.browser, previousBrowser); + } writeUnifiedConfigWithLockHeld(current); return current; }); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index de23e6ef..43dffcf6 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -822,6 +822,7 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Controls Claude browser attach and Codex browser tooling. */ export type BrowserToolPolicy = 'auto' | 'manual'; +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; export interface BrowserClaudeConfig { /** Enable Claude browser attach (default: false) */ @@ -832,6 +833,8 @@ export interface BrowserClaudeConfig { user_data_dir: string; /** DevTools port used for attach mode (default: 9222) */ devtools_port: number; + /** Eval access mode exposed through browser settings/status surfaces */ + eval_mode?: BrowserEvalMode; } export interface BrowserCodexConfig { @@ -839,6 +842,8 @@ export interface BrowserCodexConfig { enabled: boolean; /** Control whether Codex browser tooling is exposed automatically or only via --browser */ policy: BrowserToolPolicy; + /** Eval access mode exposed through browser settings/status surfaces */ + eval_mode?: BrowserEvalMode; } export interface BrowserConfig { @@ -852,10 +857,12 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { policy: 'manual', user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, policy: 'manual', + eval_mode: 'readonly', }, }; diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index d1e8c57c..397ce6e1 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -12,6 +12,7 @@ import type { ProfileType } from '../types/profile'; import { escapeShellArg, getWindowsEscapedCommandShell, + stripBrowserEnv, stripAnthropicEnv, stripClaudeCodeEnv, } from '../utils/shell-executor'; @@ -60,10 +61,10 @@ export class ClaudeAdapter implements TargetAdapter { ? stripAnthropicEnv(process.env) : process.env; - const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; + const env: NodeJS.ProcessEnv = { ...stripBrowserEnv(baseEnv), ...webSearchEnv }; if (creds.envVars) { - Object.assign(env, creds.envVars); + Object.assign(env, stripBrowserEnv(creds.envVars)); } if (creds.browserRuntimeEnv) { Object.assign(env, creds.browserRuntimeEnv); diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 3b74976b..f82aa23b 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -7,6 +7,7 @@ import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { escapeShellArg, getWindowsEscapedCommandShell, + stripBrowserEnv, stripAnthropicEnv, stripCodexSessionEnv, } from '../utils/shell-executor'; @@ -252,7 +253,7 @@ export class CodexAdapter implements TargetAdapter { buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { - ...stripCodexSessionEnv(stripAnthropicEnv(process.env)), + ...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))), }; delete env[CODEX_RUNTIME_ENV_KEY]; if (profileType !== 'default') { diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index be8838d7..89faa134 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { BrowserConfig } from '../../config/unified-config-types'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getCcsDir, getCcsPathDisplay } from '../config-manager'; import { expandPath } from '../helpers'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; @@ -15,6 +15,7 @@ export interface EffectiveClaudeBrowserAttachConfig { userDataDir: string; devtoolsPort: number; hasExplicitDevtoolsPort: boolean; + evalMode: BrowserEvalMode; } export interface BrowserLaunchCommands { @@ -230,15 +231,17 @@ export function getEffectiveClaudeBrowserAttachConfig( const configUserDataDir = resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); const configPort = normalizeDevtoolsPort(config.claude.devtools_port); + const configEvalMode = config.claude.eval_mode ?? 'readonly'; if (override.userDataDir) { return { - enabled: true, + enabled: config.claude.enabled, source: override.source as BrowserOverrideSource, overrideActive: true, userDataDir: override.userDataDir, devtoolsPort: override.devtoolsPort ?? configPort, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, + evalMode: configEvalMode, }; } @@ -249,6 +252,7 @@ export function getEffectiveClaudeBrowserAttachConfig( userDataDir: configUserDataDir, devtoolsPort: configPort, hasExplicitDevtoolsPort: true, + evalMode: configEvalMode, }; } @@ -267,11 +271,15 @@ export async function resolveOptionalBrowserAttachRuntime( } try { + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: config.userDataDir, + devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, + }); return { - runtimeEnv: await resolveBrowserRuntimeEnv({ - profileDir: config.userDataDir, - devtoolsPort: config.hasExplicitDevtoolsPort ? String(config.devtoolsPort) : undefined, - }), + runtimeEnv: { + ...runtimeEnv, + CCS_BROWSER_EVAL_MODE: config.evalMode, + }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 42b72222..2fbe14ef 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -1,5 +1,9 @@ import * as path from 'path'; -import type { BrowserConfig, BrowserToolPolicy } from '../../config/unified-config-types'; +import type { + BrowserConfig, + BrowserEvalMode, + BrowserToolPolicy, +} from '../../config/unified-config-types'; import { getBrowserConfig, loadUnifiedConfig } from '../../config/unified-config-loader'; import { getCcsPathDisplay } from '../config-manager'; import { getCodexBinaryInfo } from '../../targets/codex-detector'; @@ -19,6 +23,7 @@ import { export interface ClaudeBrowserStatus { enabled: boolean; policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; overrideActive: boolean; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; @@ -37,6 +42,7 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; @@ -69,6 +75,12 @@ function resolveSafeBrowserPolicy(policy: BrowserToolPolicy | undefined): Browse return policy === 'auto' || policy === 'manual' ? policy : 'manual'; } +function resolveSafeBrowserEvalMode(evalMode: BrowserEvalMode | undefined): BrowserEvalMode { + return evalMode === 'disabled' || evalMode === 'readonly' || evalMode === 'readwrite' + ? evalMode + : 'readonly'; +} + export function getUserFacingBrowserConfig(): BrowserConfig { const canonical = getBrowserConfig(); const persisted = loadUnifiedConfig()?.browser as PersistedBrowserConfig | undefined; @@ -79,11 +91,13 @@ export function getUserFacingBrowserConfig(): BrowserConfig { ...canonical.claude, enabled: false, policy: 'manual', + eval_mode: resolveSafeBrowserEvalMode(canonical.claude.eval_mode), }, codex: { ...canonical.codex, enabled: false, policy: 'manual', + eval_mode: resolveSafeBrowserEvalMode(canonical.codex.eval_mode), }, }; } @@ -93,11 +107,17 @@ export function getUserFacingBrowserConfig(): BrowserConfig { ...canonical.claude, enabled: persisted.claude?.enabled ?? false, policy: resolveSafeBrowserPolicy(persisted.claude?.policy), + eval_mode: resolveSafeBrowserEvalMode( + persisted.claude?.eval_mode ?? canonical.claude.eval_mode + ), }, codex: { ...canonical.codex, enabled: persisted.codex?.enabled ?? false, policy: resolveSafeBrowserPolicy(persisted.codex?.policy), + eval_mode: resolveSafeBrowserEvalMode( + persisted.codex?.eval_mode ?? canonical.codex.eval_mode + ), }, }; } @@ -110,6 +130,7 @@ async function buildClaudeBrowserStatus( const base: Omit = { enabled: effective.enabled, policy: browserConfig.claude.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.claude.eval_mode), source: effective.source, overrideActive: effective.overrideActive, effectiveUserDataDir: effective.userDataDir, @@ -224,6 +245,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): return { enabled: false, policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'disabled', title: 'Codex Browser Tools are disabled.', detail: @@ -242,6 +264,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): return { enabled: true, policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'unsupported_build', title: 'Codex Browser Tools need a Codex build with --config override support.', detail: binaryInfo @@ -258,6 +281,7 @@ function buildCodexBrowserStatus(browserConfig = getUserFacingBrowserConfig()): return { enabled: true, policy: browserConfig.codex.policy, + evalMode: resolveSafeBrowserEvalMode(browserConfig.codex.eval_mode), state: 'enabled', title: 'Codex Browser Tools are enabled.', detail: @@ -283,5 +307,6 @@ export function getManagedBrowserSetupHint(): string { userDataDir: getRecommendedBrowserUserDataDir(), devtoolsPort: 9222, hasExplicitDevtoolsPort: true, + evalMode: 'readonly', }).join('\n'); } diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 1bae3cf8..d45b218d 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -109,9 +109,14 @@ export function getImageAnalysisHookEnv( : resolveImageAnalysisStatus({ profileName: '' }, config); const skipImageAnalysis = !status.supported; const runtimeApiKey = - typeof context === 'object' && context.cliproxyBridge + !skipImageAnalysis && typeof context === 'object' && context.cliproxyBridge ? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey : ''; + const runtimePath = skipImageAnalysis ? '' : status.runtimePath || ''; + const runtimeBaseUrl = + skipImageAnalysis || typeof context !== 'object' + ? '' + : context.cliproxyBridge?.currentBaseUrl || ''; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', @@ -120,9 +125,8 @@ export function getImageAnalysisHookEnv( CCS_CURRENT_PROVIDER: status.backendId || '', CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '', CCS_IMAGE_ANALYSIS_MODEL: status.model || '', - CCS_IMAGE_ANALYSIS_RUNTIME_PATH: status.runtimePath || '', - CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: - typeof context === 'object' ? context.cliproxyBridge?.currentBaseUrl || '' : '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: runtimePath, + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: runtimeBaseUrl, ...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}), CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(), CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 10482f19..a6a5fd63 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -91,6 +91,22 @@ function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string | } } +/** + * Strip inherited browser attach/runtime env vars from a process environment. + * + * Browser capability is opt-in and launch-scoped. Stale CCS_BROWSER_* values + * from the parent process must never bleed into a browser-off child launch. + */ +export function stripBrowserEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + if (!key.toUpperCase().startsWith('CCS_BROWSER_')) { + result[key] = env[key]; + } + } + return result; +} + /** * Strip Claude Code nested-session guard env var from a process environment. * @@ -210,11 +226,12 @@ export function execClaude( const profileType = envVars?.CCS_PROFILE_TYPE; const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default'; const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1'; - const baseEnv = stripInheritedAnthropicEnv + const inheritedEnv = stripInheritedAnthropicEnv ? stripAnthropicEnv(process.env) : stripInheritedAnthropicRoutingEnv ? stripAnthropicRoutingEnv(process.env) : process.env; + const baseEnv = stripBrowserEnv(inheritedEnv); // Prepare environment (merge with base env if envVars provided) const mergedEnv = envVars diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 7ea23145..c3f04f04 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -3,7 +3,7 @@ * Session-based auth with httpOnly cookies for CCS dashboard. */ -import type { Request, Response, NextFunction } from 'express'; +import type { NextFunction, Request, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 215960fb..f25dcc2f 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { mutateUnifiedConfig } from '../../config/unified-config-loader'; -import type { BrowserConfig } from '../../config/unified-config-types'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getBrowserStatus } from '../../utils/browser'; import { getUserFacingBrowserConfig } from '../../utils/browser/browser-status'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; @@ -15,10 +15,12 @@ interface BrowserRouteBody { policy?: 'auto' | 'manual'; userDataDir?: string; devtoolsPort?: number; + evalMode?: BrowserEvalMode; }; codex?: { enabled?: boolean; policy?: 'auto' | 'manual'; + evalMode?: BrowserEvalMode; }; } @@ -30,6 +32,10 @@ function isValidBrowserPolicy(value: string): value is 'auto' | 'manual' { return value === 'auto' || value === 'manual'; } +function isValidBrowserEvalMode(value: string): value is BrowserEvalMode { + return value === 'disabled' || value === 'readonly' || value === 'readwrite'; +} + function isValidDevtoolsPort(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 65535; } @@ -88,7 +94,11 @@ router.put('/', async (req: Request, res: Response): Promise => { } const unknownClaudeKeys = Object.keys(claude ?? {}).filter( (key) => - key !== 'enabled' && key !== 'policy' && key !== 'userDataDir' && key !== 'devtoolsPort' + key !== 'enabled' && + key !== 'policy' && + key !== 'userDataDir' && + key !== 'devtoolsPort' && + key !== 'evalMode' ); if (unknownClaudeKeys.length > 0) { res.status(400).json({ @@ -97,7 +107,7 @@ router.put('/', async (req: Request, res: Response): Promise => { return; } const unknownCodexKeys = Object.keys(codex ?? {}).filter( - (key) => key !== 'enabled' && key !== 'policy' + (key) => key !== 'enabled' && key !== 'policy' && key !== 'evalMode' ); if (unknownCodexKeys.length > 0) { res.status(400).json({ @@ -129,6 +139,15 @@ router.put('/', async (req: Request, res: Response): Promise => { }); return; } + if ( + claude?.evalMode !== undefined && + (typeof claude.evalMode !== 'string' || !isValidBrowserEvalMode(claude.evalMode)) + ) { + res.status(400).json({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') { res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); return; @@ -140,6 +159,15 @@ router.put('/', async (req: Request, res: Response): Promise => { res.status(400).json({ error: 'Invalid value for codex.policy. Must be auto or manual.' }); return; } + if ( + codex?.evalMode !== undefined && + (typeof codex.evalMode !== 'string' || !isValidBrowserEvalMode(codex.evalMode)) + ) { + res.status(400).json({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } try { const current = getUserFacingBrowserConfig(); @@ -152,10 +180,12 @@ router.put('/', async (req: Request, res: Response): Promise => { policy: claude?.policy ?? current.claude.policy, user_data_dir: nextClaudeUserDataDir, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, + eval_mode: claude?.evalMode ?? current.claude.eval_mode, }, codex: { enabled: codex?.enabled ?? current.codex.enabled, policy: codex?.policy ?? current.codex.policy, + eval_mode: codex?.evalMode ?? current.codex.eval_mode, }, }; }); @@ -181,10 +211,12 @@ function toBrowserRouteConfig(config: BrowserConfig) { policy: config.claude.policy, userDataDir: config.claude.user_data_dir, devtoolsPort: config.claude.devtools_port, + evalMode: config.claude.eval_mode ?? 'readonly', }, codex: { enabled: config.codex.enabled, policy: config.codex.policy, + evalMode: config.codex.eval_mode ?? 'readonly', }, }; } 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/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 90a3d460..0beb80f1 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -202,10 +202,12 @@ describe('migration-manager legacy kimi compatibility', () => { policy: 'manual', user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, policy: 'manual', + eval_mode: 'readonly', }, }); }); 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 6cab5f48..00000000 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ /dev/null @@ -1,1179 +0,0 @@ -import { afterEach, describe, expect, it } from 'bun:test'; -import { spawn } from 'child_process'; -import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import * as http from 'node:http'; -import { WebSocketServer } from 'ws'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -type JsonRpcMessage = Record; - -type MockPageState = { - id: string; - title: string; - currentUrl: string; - readyStateSequence?: string[]; - visibleText?: string; - domSnapshot?: string; - navigate?: Record; - click?: Record< - string, - { - error?: string; - disabled?: boolean; - detached?: boolean; - hidden?: boolean; - requireMouseSequence?: boolean; - requireNativeClick?: boolean; - forbidSyntheticClickEvent?: boolean; - cancelMouseDown?: boolean; - cancelMouseUp?: boolean; - detachAfterMouseDown?: boolean; - mouseSequenceError?: string; - } - >; - screenshot?: { - data?: string; - lastCaptureBeyondViewport?: boolean; - }; - type?: Record< - string, - { - kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; - inputType?: string; - value?: string; - expectedValueWhenClearFirst?: string; - expectedValueWhenAppend?: string; - requireFocus?: boolean; - focused?: boolean; - } - >; -}; - -const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); - -type RunMcpRequestsOptions = { - serverPath?: string; - childEnv?: NodeJS.ProcessEnv; -}; - -function encodeMessage(message: unknown): string { - return `${JSON.stringify(message)}\n`; -} - -function collectResponses( - child: ReturnType, - expectedCount: number -): Promise>> { - return new Promise((resolve, reject) => { - let buffer = Buffer.alloc(0); - const responses: Array> = []; - const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); - - function tryParse(): void { - while (true) { - const newlineIndex = buffer.indexOf('\n'); - if (newlineIndex === -1) { - return; - } - - const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); - buffer = buffer.subarray(newlineIndex + 1); - if (!body) { - continue; - } - - responses.push(JSON.parse(body) as Record); - if (responses.length >= expectedCount) { - clearTimeout(timer); - resolve(responses); - return; - } - } - } - - if (!child.stdout) { - clearTimeout(timer); - reject(new Error('MCP child stdout is unavailable')); - return; - } - - child.stdout.on('data', (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); - try { - tryParse(); - } catch (error) { - clearTimeout(timer); - reject(error); - } - }); - - child.on('error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); -} - -function getResponseText(message: Record | undefined): string { - const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; - return result.content?.[0]?.text || ''; -} - -function parseJsonArgument(expression: string, key: string): string | undefined { - const marker = `const ${key} = JSON.parse(`; - const start = expression.indexOf(marker); - if (start === -1) { - return undefined; - } - - const quoteStart = start + marker.length; - const quote = expression[quoteStart]; - if (quote !== '"' && quote !== "'") { - return undefined; - } - - let index = quoteStart + 1; - while (index < expression.length) { - if (expression[index] === '\\') { - index += 2; - continue; - } - if (expression[index] === quote) { - const encoded = expression.slice(quoteStart, index + 1); - const decoded = JSON.parse(encoded) as string; - if (!decoded.startsWith('"') && !decoded.startsWith("'")) { - return undefined; - } - return JSON.parse(decoded) as string; - } - index += 1; - } - - return undefined; -} - -function createMockBrowser(pagesInput: MockPageState[]) { - let tempDir = ''; - let httpServer: http.Server | null = null; - let wsServer: WebSocketServer | null = null; - const pageStates = new Map(); - - for (const [index, page] of pagesInput.entries()) { - pageStates.set(`/devtools/page/${index + 1}`, { - visibleText: 'Hello from visible text', - domSnapshot: 'Hello from DOM snapshot', - ...page, - }); - } - - async function start(options: RunMcpRequestsOptions = {}) { - const entryServerPath = options.serverPath || bundledServerPath; - const childEnv = options.childEnv || {}; - tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); - - const port = await new Promise((resolve, reject) => { - httpServer = http.createServer((req, res) => { - if (req.url === '/json/list') { - const address = httpServer?.address(); - const serverPort = address && typeof address !== 'string' ? address.port : 0; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify( - Array.from(pageStates.entries()).map(([wsPath, page]) => ({ - id: page.id, - type: 'page', - title: page.title, - url: page.currentUrl, - webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, - })) - ) - ); - return; - } - res.writeHead(404); - res.end('not found'); - }); - - httpServer.once('error', reject); - httpServer.listen(0, '127.0.0.1', () => { - const address = httpServer?.address(); - if (!address || typeof address === 'string') { - reject(new Error('Failed to resolve mock browser server port')); - return; - } - resolve(address.port); - }); - }); - - wsServer = new WebSocketServer({ server: httpServer as http.Server }); - wsServer.on('connection', (socket, request) => { - const page = pageStates.get(request.url || ''); - if (!page) { - socket.close(); - return; - } - - socket.on('message', (raw) => { - const message = JSON.parse(raw.toString()) as { - id: number; - method: string; - params?: Record; - }; - - function reply(result: unknown): void { - socket.send(JSON.stringify({ id: message.id, result })); - } - - function replyError(errorText: string): void { - socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); - } - - if (message.method === 'Page.navigate') { - const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; - const navigatePlan = page.navigate?.[targetUrl]; - if (navigatePlan?.errorText) { - reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); - return; - } - if (navigatePlan) { - page.currentUrl = navigatePlan.finalUrl; - page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; - } - reply({ frameId: 'frame-1' }); - return; - } - - if (message.method === 'Page.captureScreenshot') { - if (!page.screenshot) { - reply({ data: '' }); - return; - } - page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; - reply({ data: page.screenshot.data || '' }); - return; - } - - if (message.method !== 'Runtime.evaluate') { - return; - } - - const expression = String(message.params?.expression || ''); - - if (expression.includes('document.title') && expression.includes('location.href')) { - reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); - return; - } - - if (expression.includes('document.body ? document.body.innerText')) { - reply({ result: { type: 'string', value: page.visibleText || '' } }); - return; - } - - if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { - reply({ result: { type: 'string', value: page.domSnapshot || '' } }); - return; - } - - if (expression.includes('document.readyState') && expression.includes('location.href')) { - const readyState = page.readyStateSequence?.shift() || 'complete'; - reply({ - result: { - type: 'string', - value: JSON.stringify({ href: page.currentUrl, readyState }), - }, - }); - return; - } - - if (expression.includes('scrollIntoView') && expression.includes('.click()')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const clickPlan = page.click?.[selector]; - const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); - const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); - const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; - const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); - const readsDispatchResult = expression.includes('const dispatchResult = {'); - const gatesNativeClickOnDispatchResult = expression.includes('if (!dispatchResult.shouldActivate)'); - const checksIsConnectedBeforeNativeClick = expression.includes('if (!element.isConnected)'); - const catchIndex = expression.indexOf('catch (mouseError) {'); - const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); - const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( - (match) => match.index ?? -1 - ); - const attemptedFallbackClick = nativeClickIndexes.some( - (index) => catchIndex !== -1 && catchBlockEnd !== -1 && index > catchIndex && index < catchBlockEnd - ); - const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( - (index) => - catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd - ); - if (!clickPlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (clickPlan.detached && expression.includes('element.isConnected')) { - replyError(`element is detached for selector: ${selector}`); - return; - } - if (clickPlan.disabled) { - replyError(`element is disabled for selector: ${selector}`); - return; - } - if (clickPlan.hidden && expression.includes('getBoundingClientRect')) { - replyError(`element is hidden or not interactable for selector: ${selector}`); - return; - } - if (clickPlan.requireMouseSequence && !attemptedMouseSequence) { - replyError(`mousedown/mouseup required for selector: ${selector}`); - return; - } - if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { - replyError(`synthetic click event forbidden for selector: ${selector}`); - return; - } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) { - replyError(`dispatch result must be checked for selector: ${selector}`); - return; - } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { - replyError(`native click must be gated for selector: ${selector}`); - return; - } - if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { - replyError(`connected state must be rechecked for selector: ${selector}`); - return; - } - if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { - replyError(`native click required for selector: ${selector}`); - return; - } - if (clickPlan.mouseSequenceError) { - if (!attemptedMouseSequence) { - replyError(`mousedown/mouseup required for selector: ${selector}`); - return; - } - if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { - reply({ result: { type: 'string', value: 'ok' } }); - return; - } - replyError(clickPlan.mouseSequenceError); - return; - } - if (clickPlan.error) { - replyError(clickPlan.error); - return; - } - reply({ result: { type: 'string', value: 'ok' } }); - return; - } - - if (expression.includes('focusTarget') && expression.includes('typedLength')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const text = parseJsonArgument(expression, 'text') ?? ''; - const clearFirst = expression.includes('const clearFirst = true'); - const typePlan = page.type?.[selector]; - if (!typePlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (typePlan.kind === 'unsupported') { - replyError(`element is not text-editable for selector: ${selector}`); - return; - } - if (typePlan.kind === 'noneditable') { - replyError(`element is not text-editable for selector: ${selector}`); - return; - } - if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { - replyError(`focus was not requested for selector: ${selector}`); - return; - } - - const currentValue = typePlan.value || ''; - const expectedValue = clearFirst - ? (typePlan.expectedValueWhenClearFirst ?? text) - : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); - - typePlan.focused = true; - typePlan.value = expectedValue; - reply({ - result: { - type: 'string', - value: JSON.stringify({ - value: expectedValue, - typedLength: expectedValue.length, - }), - }, - }); - return; - } - }); - }); - - const child = spawn('node', [entryServerPath], { - cwd: tempDir, - env: { - ...process.env, - ...childEnv, - CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - return child; - } - - async function stop() { - await new Promise((resolve) => { - wsServer?.close(() => resolve()); - if (!wsServer) resolve(); - }); - wsServer = null; - - await new Promise((resolve) => { - httpServer?.close(() => resolve()); - if (!httpServer) resolve(); - }); - httpServer = null; - - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - tempDir = ''; - } - } - - return { start, stop }; -} - -async function runMcpRequests( - pages: MockPageState[], - requests: JsonRpcMessage[], - options: RunMcpRequestsOptions = {} -) { - const browser = createMockBrowser(pages); - const child = await browser.start(options); - - try { - const responsesPromise = collectResponses(child, requests.length + 1); - child.stdin.write( - encodeMessage({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'bun-test', version: '1.0.0' }, - }, - }) - ); - - for (const request of requests) { - child.stdin.write(encodeMessage(request)); - } - - return await responsesPromise; - } finally { - child.kill(); - await browser.stop(); - } -} - -describe('ccs-browser MCP server', () => { - afterEach(() => { - // Cleanup is handled per test via runMcpRequests/createMockBrowser. - }); - - it('lists browser tools including navigate, click, type, and screenshot', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], - [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] - ); - - const tools = (responses.find((message) => message.id === 2)?.result as { - tools: Array<{ - name: string; - description?: string; - inputSchema?: { properties?: Record }; - }>; - }).tools; - - expect(tools.map((tool) => tool.name)).toEqual([ - 'browser_get_session_info', - 'browser_get_url_and_title', - 'browser_get_visible_text', - 'browser_get_dom_snapshot', - 'browser_navigate', - 'browser_click', - 'browser_type', - 'browser_take_screenshot', - ]); - - const clickTool = tools.find((tool) => tool.name === 'browser_click'); - expect(clickTool?.description).toContain('mouse event chain'); - expect(clickTool?.description).not.toContain('synthetic element.click()'); - - for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { - expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ - type: 'integer', - minimum: 0, - }); - } - }); - - it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { - const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); - const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); - const bootstrapServerPath = join(installDir, 'bootstrap.cjs'); - - try { - cpSync(bundledServerPath, installedServerPath); - writeFileSync( - bootstrapServerPath, - 'delete globalThis.WebSocket;\nrequire("./ccs-browser-server.cjs");\n', - 'utf8' - ); - - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_get_url_and_title', arguments: {} }, - }, - ], - { - serverPath: bootstrapServerPath, - childEnv: { - NODE_PATH: join(process.cwd(), 'node_modules'), - }, - } - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(response)).toContain('title: Installed Copy'); - expect(getResponseText(response)).toContain('url: https://example.com/'); - } finally { - rmSync(installDir, { recursive: true, force: true }); - } - }); - - it('navigates successfully after readiness polling', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - navigate: { - 'https://example.com/next': { - finalUrl: 'https://example.com/next', - readyStates: ['loading', 'interactive'], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/next' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('pageIndex: 0'); - expect(text).toContain('url: https://example.com/next'); - expect(text).toContain('status: navigated'); - }); - - it( - 'returns a handled error when navigation readiness times out', - async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - navigate: { - 'https://example.com/slow': { - finalUrl: 'https://example.com/', - readyStates: new Array(60).fill('loading'), - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/slow' }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); - }, - 8000 - ); - - it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required'); - expect(getResponseText(responses.find((message) => message.id === 3))).toContain( - 'Browser MCP failed: url must be an absolute http or https URL' - ); - expect(getResponseText(responses.find((message) => message.id === 4))).toContain( - 'Browser MCP failed: pageIndex must be a non-negative integer' - ); - expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range'); - }); - - it('surfaces Page.navigate CDP failures as handled errors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/start', - navigate: { - 'https://example.com/fail': { - finalUrl: 'https://example.com/start', - errorText: 'net::ERR_ABORTED', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/fail' }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain( - 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' - ); - }); - - it('treats redirects as successful navigation', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/start', - navigate: { - 'https://example.com/redirect': { - finalUrl: 'https://example.com/final', - readyStates: ['loading', 'interactive'], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/redirect' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('status: navigated'); - expect(text).toContain('url: https://example.com/final'); - }); - - it('clicks matching elements and reports selector failures', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#submit': {}, - '#disabled': { disabled: true }, - '#throws': { error: 'click exploded' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#submit' } }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#missing' } }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#disabled' } }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#throws' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); - - const selectorMiss = responses.find((message) => message.id === 3); - expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing'); - - const disabledError = responses.find((message) => message.id === 4); - expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); - - const pageSideError = responses.find((message) => message.id === 5); - expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(pageSideError)).toContain('click exploded'); - }); - - it('reports detached and hidden click targets as handled errors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#hidden': { hidden: true }, - '#detached': { detached: true }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#hidden' } }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#detached' } }, - }, - ] - ); - - const hiddenError = responses.find((message) => message.id === 2); - expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(hiddenError)).toContain('element is hidden or not interactable for selector: #hidden'); - - const detachedError = responses.find((message) => message.id === 3); - expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); - }); - - it('uses a mouse sequence when the target requires it', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#menu-trigger': { requireMouseSequence: true }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #menu-trigger'); - }); - - it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#click-event': { - requireMouseSequence: true, - forbidSyntheticClickEvent: true, - requireNativeClick: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#click-event' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #click-event'); - }); - - it('preserves native click activation after the mouse sequence', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#native-click': { - requireMouseSequence: true, - requireNativeClick: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#native-click' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #native-click'); - }); - - it('does not force activation when mousedown cancels the interaction', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#cancel-mousedown': { - requireMouseSequence: true, - cancelMouseDown: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); - }); - - it('rechecks connectivity before native activation after the mouse sequence', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#detached-during-click': { - requireMouseSequence: true, - requireNativeClick: true, - detachAfterMouseDown: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); - }); - - it('falls back to click when mouse sequence dispatch fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#fallback' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback'); - }); - - it('captures screenshots and reports empty payload failures', async () => { - const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - screenshot: screenshotPlan, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_screenshot', - arguments: { fullPage: true }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('pageIndex: 0'); - expect(text).toContain('format: png'); - expect(text).toContain('fullPage: true'); - expect(text).toContain('data: c2NyZWVuc2hvdA=='); - expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); - - const errorResponses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - screenshot: { data: '' }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_screenshot', - arguments: {}, - }, - }, - ] - ); - - const errorResponse = errorResponses.find((message) => message.id === 2); - expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed'); - }); - - it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - type: { - 'input[name="email"]': { - kind: 'input', - inputType: 'email', - value: 'old@example.com', - expectedValueWhenAppend: 'old@example.comhi@example.com', - requireFocus: true, - }, - '#notes': { - kind: 'textarea', - value: 'old note', - expectedValueWhenClearFirst: '', - requireFocus: true, - }, - '#editor': { - kind: 'contenteditable', - value: 'old content', - expectedValueWhenAppend: 'old contentrich text', - requireFocus: true, - }, - '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, - '#plain': { kind: 'noneditable', value: 'plain' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, - }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#notes', text: '', clearFirst: true }, - }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#editor', text: 'rich text' }, - }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#color', text: 'ignored' }, - }, - }, - { - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#plain', text: 'ignored' }, - }, - }, - ] - ); - - const typed = getResponseText(responses.find((message) => message.id === 2)); - expect(typed).toContain('status: typed'); - expect(typed).toContain('selector: input[name="email"]'); - expect(typed).toContain('typedLength: 29'); - - const clearFirst = getResponseText(responses.find((message) => message.id === 3)); - expect(clearFirst).toContain('status: typed'); - expect(clearFirst).toContain('selector: #notes'); - expect(clearFirst).toContain('typedLength: 0'); - - const contenteditable = getResponseText(responses.find((message) => message.id === 4)); - expect(contenteditable).toContain('status: typed'); - expect(contenteditable).toContain('selector: #editor'); - expect(contenteditable).toContain('typedLength: 20'); - - const unsupported = responses.find((message) => message.id === 5); - expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color'); - - const nonEditable = responses.find((message) => message.id === 6); - expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain'); - }); -}); diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js index d9c79ef2..0e1618cd 100644 --- a/tests/unit/scripts/run-test-bucket.test.js +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -3,6 +3,15 @@ const path = require('node:path'); const bucket = require('../../../scripts/run-test-bucket.js'); describe('run-test-bucket', () => { + const browserMcpSplitSuites = [ + 'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts', + 'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts', + 'tests/unit/hooks/browser-mcp-navigation-and-query.test.ts', + 'tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts', + 'tests/unit/hooks/browser-mcp-recording-and-replay.test.ts', + 'tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts', + ]; + test('all declared slow tests still exist on disk', () => { for (const relativePath of bucket.slowTests) { const absolutePath = path.resolve(__dirname, '../../../', relativePath); @@ -10,6 +19,15 @@ describe('run-test-bucket', () => { } }); + test('keeps the split Browser MCP suites in the slow bucket', () => { + const slowSet = bucket.getSlowSet(); + + expect(slowSet.has('tests/unit/hooks/ccs-browser-mcp-server.test.ts')).toBe(false); + for (const relativePath of browserMcpSplitSuites) { + expect(slowSet.has(relativePath)).toBe(true); + } + }); + test('forces npm tests into the slow bucket', () => { expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 833332d7..9d79ebce 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -124,6 +124,9 @@ if (envOut) { CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN, CODEX_THREAD_ID: process.env.CODEX_THREAD_ID, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR, + CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR, + CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL, }) + '\\n' ); } @@ -173,12 +176,28 @@ process.exit(0); CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_THINKING: '8192', + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-codex-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-codex-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-codex-env', }); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); expect(calls).toEqual([['fix failing tests']]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: undefined, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); }); it('injects browser MCP runtime overrides when Codex browser policy is explicitly auto-enabled', () => { @@ -493,6 +512,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }, ]); }); @@ -533,6 +555,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }); }); @@ -611,6 +636,9 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, }, ]); }); diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index be81261b..28d92d3a 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -119,6 +119,7 @@ describe('default profile browser launch', () => { printf "%s\n" "$@" > "${claudeArgsLogPath}" { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" @@ -137,6 +138,13 @@ exit 0 CCS_HOME: tmpHome, CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', + CCS_BROWSER_USER_DATA_DIR: '', + CCS_BROWSER_PROFILE_DIR: '', + CCS_BROWSER_DEVTOOLS_HOST: '', + CCS_BROWSER_DEVTOOLS_PORT: '', + CCS_BROWSER_DEVTOOLS_HTTP_URL: '', + CCS_BROWSER_DEVTOOLS_WS_URL: '', + CCS_BROWSER_EVAL_MODE: '', }; }); @@ -259,6 +267,30 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); + it('scrubs inherited CCS_BROWSER_* env from browser-off default Claude launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-browser-legacy', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9555', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9555', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-default-env', + }); + + 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/stale-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-browser-legacy'); + expect(launchedEnv).not.toContain('9555'); + expect(launchedEnv).not.toContain('stale-default-env'); + }); + it('skips managed browser attach when the default CCS browser profile directory is missing', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index fa63cbc6..e89e8855 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -131,6 +131,7 @@ describe('settings profile browser launch', () => { printf "%s\n" "$@" > "${claudeArgsLogPath}" { printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "legacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" @@ -156,6 +157,13 @@ exit 0 CCS_HOME: tmpHome, CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', + CCS_BROWSER_USER_DATA_DIR: '', + CCS_BROWSER_PROFILE_DIR: '', + CCS_BROWSER_DEVTOOLS_HOST: '', + CCS_BROWSER_DEVTOOLS_PORT: '', + CCS_BROWSER_DEVTOOLS_HTTP_URL: '', + CCS_BROWSER_DEVTOOLS_WS_URL: '', + CCS_BROWSER_EVAL_MODE: '', }; }); @@ -316,6 +324,65 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).not.toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); + it('scrubs inherited CCS_BROWSER_* env from browser-off settings-profile launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-settings-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-settings-browser-legacy', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9666', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9666', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-settings-env', + }); + + 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/stale-settings-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-settings-browser-legacy'); + expect(launchedEnv).not.toContain('9666'); + 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; diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 1430fd72..ee2d7187 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read'; +setDefaultTimeout(30000); interface RunResult { status: number | null; @@ -98,6 +99,9 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY" printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL" printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH" + printf "browserUserDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR" + printf "browserLegacyProfileDir=%s\n" "$CCS_BROWSER_PROFILE_DIR" + printf "browserWsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" } > "${claudeEnvLogPath}" exit 0 `, @@ -113,6 +117,13 @@ exit 0 CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', }; + delete baseEnv.CCS_BROWSER_USER_DATA_DIR; + delete baseEnv.CCS_BROWSER_PROFILE_DIR; + delete baseEnv.CCS_BROWSER_DEVTOOLS_PORT; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HOST; + delete baseEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete baseEnv.CCS_BROWSER_DEVTOOLS_WS_URL; + delete baseEnv.CCS_BROWSER_EVAL_MODE; }); afterEach(() => { @@ -167,7 +178,12 @@ exit 0 expect(result.status).toBe(0); expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool'); const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + expect(launchedEnv).toContain('skip=1'); + expect(launchedEnv).not.toContain('runtimeApiKey=current-token'); + expect(launchedEnv).not.toContain('runtimeBaseUrl=https://api.z.ai'); + expect(launchedEnv).not.toContain('runtimePath=/api/provider/agy'); }); it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => { @@ -245,9 +261,26 @@ exit 0 expect(result.status).toBe(0); expect(fs.existsSync(claudeEnvLogPath)).toBe(true); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain('runtimeApiKey=current-token'); expect(launchedEnv).not.toContain('stale-token'); - expect(launchedEnv).toContain('runtimePath=/api/provider/agy'); + expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); + }); + + it('scrubs stale CCS_BROWSER_* env while preserving settings-profile image analysis runtime', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + CCS_BROWSER_USER_DATA_DIR: '/tmp/stale-image-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/stale-image-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/stale-image-env', + }); + + expect(result.status).toBe(0); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('stale-token'); + expect(launchedEnv).not.toContain('/tmp/stale-image-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/stale-image-browser-legacy'); + expect(launchedEnv).not.toContain('stale-image-env'); }); it('pins direct settings image analysis to the current local CLIProxy auth token', () => { @@ -273,8 +306,7 @@ exit 0 expect(result.status).toBe(0); expect(fs.existsSync(claudeEnvLogPath)).toBe(true); const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); - expect(launchedEnv).toContain('runtimeApiKey=current-token'); expect(launchedEnv).not.toContain('stale-token'); - expect(launchedEnv).toContain('runtimePath=/api/provider/'); + expect(launchedEnv).not.toContain('runtimeApiKey=stale-token'); }); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 65c037d5..1fc660c5 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -126,10 +126,12 @@ describe('unified-config-types', () => { policy: 'manual', user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, policy: 'manual', + eval_mode: 'readonly', }, }); }); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index abaeaaff..9add8441 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -261,6 +261,35 @@ describe('browser status', () => { expect(resolution.warning).toContain('ccs browser doctor'); }); + it('keeps env override paths from implicitly enabling Claude browser attach when config is disabled', () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); + process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; + process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; + + const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + + expect(effective).toMatchObject({ + enabled: false, + source: 'CCS_BROWSER_USER_DATA_DIR', + overrideActive: true, + userDataDir: '/env-browser', + devtoolsPort: 9444, + }); + }); + it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => { const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); mkdirSync(managedDir, { recursive: true }); @@ -318,6 +347,20 @@ describe('browser status', () => { }); it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ diff --git a/tests/unit/utils/browser/chrome-reuse.test.ts b/tests/unit/utils/browser/chrome-reuse.test.ts index f9b90c5c..2b0915ab 100644 --- a/tests/unit/utils/browser/chrome-reuse.test.ts +++ b/tests/unit/utils/browser/chrome-reuse.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -12,6 +12,13 @@ describe('chrome reuse resolver', () => { const originalHome = process.env.HOME; const originalLocalAppData = process.env.LOCALAPPDATA; const originalUserProfile = process.env.USERPROFILE; + const originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + const originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + const originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + const originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; + const originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + const originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + const originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; let tempDirs: string[] = []; let servers: Array<{ stop: () => void }> = []; @@ -71,7 +78,7 @@ describe('chrome reuse resolver', () => { return server; } - async function reserveClosedPort(): Promise { + function reserveClosedPort(): number { const server = Bun.serve({ port: 0, fetch() { @@ -79,13 +86,27 @@ describe('chrome reuse resolver', () => { }, }); const port = server.port; - server.stop(true); + if (port === undefined) { + server.stop(); + throw new Error('Failed to reserve a DevTools test port'); + } + server.stop(); return port; } + beforeEach(() => { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + delete process.env.CCS_BROWSER_EVAL_MODE; + }); + afterEach(() => { for (const server of servers) { - server.stop(true); + server.stop(); } servers = []; @@ -111,6 +132,48 @@ describe('chrome reuse resolver', () => { } else { process.env.USERPROFILE = originalUserProfile; } + + if (originalBrowserUserDataDir === undefined) { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } else { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } + + if (originalBrowserProfileDir === undefined) { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } else { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } + + if (originalBrowserDevtoolsPort === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } else { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } + + if (originalBrowserDevtoolsHost === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + } else { + process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; + } + + if (originalBrowserDevtoolsHttpUrl === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + } else { + process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl; + } + + if (originalBrowserDevtoolsWsUrl === undefined) { + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + } else { + process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl; + } + + if (originalBrowserEvalMode === undefined) { + delete process.env.CCS_BROWSER_EVAL_MODE; + } else { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } }); it('uses explicit profile-dir before the default path and resolves the websocket target', async () => { @@ -175,7 +238,7 @@ describe('chrome reuse resolver', () => { it('throws a clear error when DevToolsActivePort metadata is missing', async () => { const profileDir = createTempDir('ccs-chrome-missing-metadata-'); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}` ); }); @@ -184,17 +247,17 @@ describe('chrome reuse resolver', () => { const profileDir = createTempDir('ccs-chrome-invalid-metadata-'); writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target'); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}` ); }); it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => { const profileDir = createTempDir('ccs-chrome-unreachable-'); - const port = await reserveClosedPort(); + const port = reserveClosedPort(); writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}` ); }); @@ -204,7 +267,7 @@ describe('chrome reuse resolver', () => { const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' }); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version` ); }); @@ -214,7 +277,7 @@ describe('chrome reuse resolver', () => { const server = await startFailingDevToolsServer(500); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` ); }); @@ -224,7 +287,7 @@ describe('chrome reuse resolver', () => { const server = await startMalformedJsonDevToolsServer(); writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`); - await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow( `Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}` ); }); @@ -262,7 +325,7 @@ describe('chrome reuse resolver', () => { 'missing-profile' ); - await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( + expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow( `Chrome profile directory is invalid: ${missingProfileDir}` ); }); diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index d8a4e1fd..46482358 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -13,6 +13,13 @@ describe('browser routes', () => { let tempHome = ''; let originalCcsHome: string | undefined; let originalDashboardAuthEnabled: string | undefined; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsHost: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + let originalBrowserDevtoolsHttpUrl: string | undefined; + let originalBrowserDevtoolsWsUrl: string | undefined; + let originalBrowserEvalMode: string | undefined; let forcedRemoteAddress = '127.0.0.1'; beforeAll(async () => { @@ -54,8 +61,22 @@ describe('browser routes', () => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); originalCcsHome = process.env.CCS_HOME; originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; process.env.CCS_HOME = tempHome; process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + delete process.env.CCS_BROWSER_EVAL_MODE; forcedRemoteAddress = '127.0.0.1'; }); @@ -72,6 +93,42 @@ describe('browser routes', () => { delete process.env.CCS_DASHBOARD_AUTH_ENABLED; } + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + if (originalBrowserDevtoolsHost !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + } + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + if (originalBrowserDevtoolsHttpUrl !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; + } + if (originalBrowserDevtoolsWsUrl !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL; + } + if (originalBrowserEvalMode !== undefined) { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } else { + delete process.env.CCS_BROWSER_EVAL_MODE; + } + rmSync(tempHome, { recursive: true, force: true }); }); @@ -96,26 +153,64 @@ describe('browser routes', () => { policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, + evalMode: 'readonly', }, codex: { enabled: false, policy: 'manual', + evalMode: 'readonly', }, }); expect(payload.status.claude).toMatchObject({ state: 'disabled', policy: 'manual', + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', }); expect(payload.status.codex).toMatchObject({ enabled: false, state: 'disabled', policy: 'manual', + evalMode: 'readonly', serverName: 'ccs_browser', }); expect(payload.status.codex.detail).toContain('off by default'); }); + it('returns evalMode through the standalone browser status route', async () => { + const updateResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + enabled: true, + policy: 'auto', + evalMode: 'disabled', + }, + }), + }); + + expect(updateResponse.status).toBe(200); + + const response = await fetch(`${baseUrl}/api/browser/status`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + claude: { + policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + policy: 'auto', + evalMode: 'disabled', + }, + }); + }); + it('updates the saved browser config through the dashboard route', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', @@ -126,10 +221,12 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { - enabled: false, - policy: 'manual', + enabled: true, + policy: 'auto', + evalMode: 'disabled', }, }), }); @@ -142,10 +239,22 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { - enabled: false, + enabled: true, + policy: 'auto', + evalMode: 'disabled', + }, + }); + expect(payload.browser.status).toMatchObject({ + claude: { policy: 'manual', + evalMode: 'readwrite', + }, + codex: { + policy: 'auto', + evalMode: 'disabled', }, }); @@ -156,14 +265,57 @@ describe('browser routes', () => { policy: 'manual', user_data_dir: '/tmp/ccs-browser', devtools_port: 9333, + eval_mode: 'readwrite', }, codex: { - enabled: false, - policy: 'manual', + enabled: true, + policy: 'auto', + eval_mode: 'disabled', }, }); }); + it('updates evalMode without changing the saved policy', async () => { + const firstResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + enabled: true, + policy: 'auto', + evalMode: 'readonly', + }, + }), + }); + + expect(firstResponse.status).toBe(200); + + const secondResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + evalMode: 'readwrite', + }, + }), + }); + + expect(secondResponse.status).toBe(200); + const payload = await secondResponse.json(); + expect(payload.browser.config.codex).toMatchObject({ + enabled: true, + policy: 'auto', + evalMode: 'readwrite', + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser?.codex).toMatchObject({ + enabled: true, + policy: 'auto', + eval_mode: 'readwrite', + }); + }); + it('treats a blank user-data directory as a reset to the recommended path', async () => { const firstResponse = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', @@ -174,6 +326,7 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser-custom', devtoolsPort: 9333, + evalMode: 'readonly', }, }), }); @@ -197,9 +350,11 @@ describe('browser routes', () => { policy: 'manual', userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9333, + evalMode: 'readonly', }); expect(payload.browser.status.claude.state).toBe('browser_not_running'); expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile'); + expect(payload.browser.status.claude.evalMode).toBe('readonly'); expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true); const config = loadOrCreateUnifiedConfig(); @@ -209,6 +364,7 @@ describe('browser routes', () => { policy: 'manual', user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtools_port: 9333, + eval_mode: 'readonly', }, }); }); @@ -279,6 +435,23 @@ describe('browser routes', () => { }); }); + it('rejects invalid browser evalMode values at the route boundary', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + evalMode: 'always', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + }); + it('rejects unknown nested browser lane fields instead of silently ignoring them', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 0a8bc075..38e43778 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -234,14 +234,21 @@ export interface UpdateImageAnalysisSettingsPayload { profileBackends?: Record; } +export type BrowserToolPolicy = 'auto' | 'manual'; +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + export interface BrowserSettingsConfig { claude: { enabled: boolean; + policy: BrowserToolPolicy; userDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; }; codex: { enabled: boolean; + policy: BrowserToolPolicy; + evalMode: BrowserEvalMode; }; } @@ -253,6 +260,7 @@ export interface BrowserLaunchCommands { export interface ClaudeBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; overrideActive: boolean; state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; @@ -262,6 +270,7 @@ export interface ClaudeBrowserStatus { effectiveUserDataDir: string; recommendedUserDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; managedMcpServerName: string; managedMcpServerPath: string; launchCommands: BrowserLaunchCommands; @@ -270,11 +279,13 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; nextStep: string; serverName: string; + evalMode: BrowserEvalMode; supportsConfigOverrides: boolean; binaryPath: string | null; version?: string; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index b74a1194..53f26f36 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2445,6 +2445,14 @@ const resources = { nextStep: 'Next step', technicalDetails: 'Technical Details', diagnostics: 'Diagnostics', + evalMode: { + label: 'browser_eval access', + options: { + disabled: 'Disabled', + readonly: 'Read-only', + readwrite: 'Read/write', + }, + }, actions: { saveClaude: 'Save Claude settings', saveCodex: 'Save Codex settings', @@ -2471,6 +2479,8 @@ const resources = { effectivePath: 'Effective attach path', recommendedPath: 'Recommended path', managedRuntime: 'Managed browser runtime', + evalModeHint: + 'Controls browser_eval access for Claude Browser Attach. Read-only allows inspection; read/write also allows page-side mutations.', overrideMessage: 'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.', launchGuidance: 'Launch guidance', @@ -2490,6 +2500,8 @@ const resources = { overrideUnsupported: 'Not supported', binary: 'Detected Codex binary', notDetected: 'Not detected', + evalModeHint: + 'Stored for Browser settings parity. In Phase 1, browser_eval enforcement primarily applies to Claude Browser Attach.', }, }, }, @@ -4868,6 +4880,14 @@ const resources = { nextStep: '下一步', technicalDetails: '技术细节', diagnostics: '诊断信息', + evalMode: { + label: 'browser_eval 权限', + options: { + disabled: '禁用', + readonly: '只读', + readwrite: '可读写', + }, + }, actions: { saveClaude: '保存 Claude 设置', saveCodex: '保存 Codex 设置', @@ -4891,6 +4911,8 @@ const resources = { effectivePath: '当前生效的 attach 路径', recommendedPath: '推荐路径', managedRuntime: '托管浏览器运行时', + evalModeHint: + '控制 Claude Browser Attach 中 browser_eval 的权限。只读仅允许读取,读写还允许页面侧修改。', overrideMessage: '当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。', launchGuidance: '启动指引', @@ -4907,6 +4929,8 @@ const resources = { overrideUnsupported: '不支持', binary: '检测到的 Codex 可执行文件', notDetected: '未检测到', + evalModeHint: + '为保持 Browser 设置页一致性而保存该配置。Phase 1 中,browser_eval 的实际限制主要作用于 Claude Browser Attach。', }, }, }, @@ -7394,6 +7418,14 @@ const resources = { nextStep: 'Bước tiếp theo', technicalDetails: 'Chi tiết kỹ thuật', diagnostics: 'Chẩn đoán', + evalMode: { + label: 'Quyền browser_eval', + options: { + disabled: 'Tắt', + readonly: 'Chỉ đọc', + readwrite: 'Đọc/ghi', + }, + }, actions: { saveClaude: 'Lưu cấu hình Claude', saveCodex: 'Lưu cấu hình Codex', @@ -7421,6 +7453,8 @@ const resources = { effectivePath: 'Đường dẫn attach đang có hiệu lực', recommendedPath: 'Đường dẫn khuyến nghị', managedRuntime: 'Runtime trình duyệt được quản lý', + evalModeHint: + 'Điều khiển quyền browser_eval cho Claude Browser Attach. Chỉ đọc cho phép quan sát; đọc/ghi cũng cho phép thay đổi phía trang.', overrideMessage: 'Hiện có override môi trường từ {{source}}. Khi bỏ override này, Dashboard sẽ lại là nguồn cấu hình chính.', launchGuidance: 'Hướng dẫn khởi chạy', @@ -7440,6 +7474,8 @@ const resources = { overrideUnsupported: 'Không được hỗ trợ', binary: 'Binary Codex được phát hiện', notDetected: 'Không phát hiện', + evalModeHint: + 'Được lưu để giữ tương thích giao diện Browser settings. Ở Phase 1, việc áp dụng hạn chế browser_eval chủ yếu diễn ra trên Claude Browser Attach.', }, }, }, @@ -9821,6 +9857,14 @@ const resources = { nextStep: '次の手順', technicalDetails: '技術的な詳細', diagnostics: '診断情報', + evalMode: { + label: 'browser_eval 権限', + options: { + disabled: '無効', + readonly: '読み取り専用', + readwrite: '読み書き可', + }, + }, actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', @@ -9848,6 +9892,8 @@ const resources = { effectivePath: '現在有効な attach パス', recommendedPath: '推奨パス', managedRuntime: '管理済みブラウザランタイム', + evalModeHint: + 'Claude Browser Attach における browser_eval の権限を制御します。読み取り専用は参照のみ、読み書き可はページ側の変更も許可します。', overrideMessage: '{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。', launchGuidance: '起動ガイダンス', @@ -9867,6 +9913,8 @@ const resources = { overrideUnsupported: '非対応', binary: '検出された Codex バイナリ', notDetected: '未検出', + evalModeHint: + 'Browser 設定画面の整合性のために保存されます。Phase 1 では browser_eval の制御は主に Claude Browser Attach に適用されます。', }, }, }, diff --git a/ui/src/pages/settings/sections/browser/index.tsx b/ui/src/pages/settings/sections/browser/index.tsx index 6c997257..b32c2cd2 100644 --- a/ui/src/pages/settings/sections/browser/index.tsx +++ b/ui/src/pages/settings/sections/browser/index.tsx @@ -2,29 +2,36 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import { - Browser, - Gear, - CheckCircle, - WarningCircle, - XCircle, + AlertCircle, ArrowRight, - ClipboardText, - ArrowsClockwise, - TerminalWindow, - CaretDown, + CheckCircle2, + ChevronDown, + Clipboard, Info, -} from '@phosphor-icons/react'; + Monitor, + RefreshCw, + Settings, + Terminal, + XCircle, +} from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { getClientPlatformKey } from '@/lib/platform'; import { cn } from '@/lib/utils'; import { useBrowserConfig, useRawConfig } from '../../hooks'; -import type { BrowserConfig } from '../../types'; +import type { BrowserConfig, BrowserEvalMode } from '../../types'; // --- Constants & Helpers --- @@ -35,6 +42,12 @@ function parsePortDraft(value: string): number | null { return port; } +const BROWSER_EVAL_MODE_OPTIONS: BrowserEvalMode[] = ['disabled', 'readonly', 'readwrite']; + +function getBrowserEvalModeLabel(t: ReturnType['t'], mode: BrowserEvalMode) { + return t(`settingsPage.browserSection.evalMode.options.${mode}`); +} + function buildLaunchCommand( userDataDir: string, devtoolsPort: number, @@ -142,11 +155,11 @@ function StatusStrip({ )} > {isReady ? ( - + ) : isError ? ( - + ) : ( - + )}
@@ -205,10 +218,10 @@ function DiagnosticsSection({
@@ -389,12 +406,12 @@ export default function BrowserSection() { > {error ? (
- + {error}
) : (
- + {actionMessage ?? t('commonToast.settingsSaved')}
)} @@ -413,7 +430,7 @@ export default function BrowserSection() {
- +

@@ -431,7 +448,7 @@ export default function BrowserSection() { onClick={refreshAll} disabled={saving || loading} > - + {t('settings.refresh')} @@ -442,7 +459,7 @@ export default function BrowserSection() { transition={{ delay: 0.1 }} > - + {t('settingsPage.browserSection.primaryTitle')} @@ -486,9 +503,9 @@ export default function BrowserSection() { className="rounded-full px-6 shadow-md transition-transform active:scale-95" > {saving ? ( - + ) : ( - + )} {t('settingsPage.browserSection.actions.saveClaude')} @@ -498,7 +515,7 @@ export default function BrowserSection() { onClick={refreshStatus} disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid} > - + {t('settingsPage.browserSection.actions.testConnection')}

@@ -564,6 +581,36 @@ export default function BrowserSection() { : t('settingsPage.browserSection.claude.devtoolsPortHint')}

+ +
+ + +

+ {t('settingsPage.browserSection.claude.evalModeHint')} +

+
@@ -584,7 +631,7 @@ export default function BrowserSection() { onClick={copyLaunchCommand} disabled={!preferredLaunchCommand} > - +
@@ -594,7 +641,7 @@ export default function BrowserSection() { {status.claude.overrideActive && (
- + {t('settingsPage.browserSection.claude.overrideMessage', { source: status.claude.source, })} @@ -637,6 +684,16 @@ export default function BrowserSection() {

+
+

+ {t('settingsPage.browserSection.evalMode.label')} +

+
+

+ {getBrowserEvalModeLabel(t, status.claude.evalMode)} +

+
+
@@ -677,9 +734,9 @@ export default function BrowserSection() { className="rounded-full px-6 shadow-md transition-transform active:scale-95" > {saving ? ( - + ) : ( - + )} {t('settingsPage.browserSection.actions.saveCodex')} @@ -694,6 +751,36 @@ export default function BrowserSection() { nextStep={status.codex.nextStep} /> +
+ + +

+ {t('settingsPage.browserSection.codex.evalModeHint')} +

+
+
{status.codex.supportsConfigOverrides ? ( - + ) : ( - + )} {status.codex.supportsConfigOverrides ? t('settingsPage.browserSection.codex.overrideSupported') @@ -740,6 +827,16 @@ export default function BrowserSection() { )}
+
+

+ {t('settingsPage.browserSection.evalMode.label')} +

+
+

+ {getBrowserEvalModeLabel(t, status.codex.evalMode)} +

+
+
diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index a8765ee7..9784c8dc 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -4,6 +4,8 @@ */ import type { + BrowserEvalMode, + BrowserToolPolicy, BrowserSettingsConfig, BrowserStatusPayload, CliproxyServerConfig, @@ -198,7 +200,7 @@ export interface ThinkingConfig { // === Re-exports from api-client === -export type { CliproxyServerConfig, RemoteProxyStatus }; +export type { BrowserEvalMode, BrowserToolPolicy, CliproxyServerConfig, RemoteProxyStatus }; export type BrowserConfig = BrowserSettingsConfig; export type BrowserStatus = BrowserStatusPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload;