From b90eae38928cce19c246b69f2c530947adc401f4 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 00:29:43 +0800 Subject: [PATCH 01/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E4=B8=80=E9=98=B6=E6=AE=B5=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E4=B8=8E=E9=85=8D=E7=BD=AE=E6=89=93=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 browser_wait_for、browser_eval 及 click/query 扩展能力 - 打通 browser eval_mode 的配置、状态、启动链路与设置界面 - 补充浏览器相关测试隔离、文档和 CI parity 收尾调整 --- docs/browser-automation.md | 31 +- lib/mcp/ccs-browser-server.cjs | 643 ++++++++- scripts/ci-parity-gate.sh | 4 +- src/ccs.ts | 23 +- src/cliproxy/executor/index.ts | 1 + src/commands/browser-command.ts | 2 + src/config/unified-config-loader.ts | 12 + src/config/unified-config-types.ts | 8 + src/utils/browser/browser-settings.ts | 5 +- src/utils/browser/browser-status.ts | 7 + src/utils/browser/chrome-reuse.ts | 4 + src/web-server/routes/browser-routes.ts | 22 + tests/unit/commands/browser-command.test.ts | 8 + .../unit/hooks/ccs-browser-mcp-server.test.ts | 1214 ++++++++++++++++- .../targets/codex-runtime-integration.test.ts | 6 +- .../default-profile-browser-launch.test.ts | 14 +- .../settings-profile-browser-launch.test.ts | 12 + ...ings-profile-image-analysis-launch.test.ts | 18 +- .../unit/utils/browser/browser-status.test.ts | 17 + tests/unit/utils/browser/chrome-reuse.test.ts | 87 +- tests/unit/web-server/browser-routes.test.ts | 105 ++ ui/src/lib/api-client.ts | 6 + ui/src/lib/i18n.ts | 48 + .../pages/settings/sections/browser/index.tsx | 167 ++- ui/src/pages/settings/types.ts | 3 +- 25 files changed, 2370 insertions(+), 97 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 27ba99a6..6ffe5d20 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,6 +1,6 @@ # Browser Automation -Last Updated: 2026-04-16 +Last Updated: 2026-04-17 CCS provides browser automation through two separate runtime paths: @@ -20,6 +20,28 @@ that already has useful authenticated state. Claude Browser Attach requires a browser launched in attach mode with remote debugging enabled. A recent Chrome update alone is not sufficient. +The managed `ccs-browser` runtime currently exposes four tool groups: + +- **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_take_screenshot` +- **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` +- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` + +Notable Phase 1 capability details: + +- `browser_click` accepts zero-based `nth` so Claude can target the Nth matching element +- `browser_query` is multi-match aware and can return `count`, `href`, and `onclick` in addition to visibility-oriented fields +- `browser_wait_for` can wait on page text or selector state before the next step runs +- `browser_eval` is gated by `browser.claude.eval_mode` and supports `disabled`, `readonly`, or `readwrite`; readonly mode uses side-effect-blocked evaluation and may reject expressions that could mutate page state + +A common hover-debug workflow is: + +1. call `browser_hover` to move the browser pointer onto the card or trigger +2. call `browser_wait_for` if the hover state needs time to appear +3. call `browser_query` on the hover-only control to inspect `exists`, `count`, visibility, opacity, `href`, `onclick`, and bounds +4. call `browser_take_element_screenshot` to confirm the revealed state +5. call `browser_eval` in read-only mode when you need page-side inspection that the structured tools do not expose directly + ### Codex Browser Tools Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides @@ -40,10 +62,12 @@ The Browser screen exposes two sections: - enable/disable the Claude attach lane - choose the Chrome user-data directory - set the expected DevTools port + - choose the `browser_eval` access level (`disabled`, `readonly`, `readwrite`) - review readiness and next-step guidance - copy a generated browser launch command - **Codex Browser Tools** - enable/disable CCS-managed browser tooling for Codex-target launches + - choose the stored `browser_eval` access level for Browser settings parity - review whether the detected Codex build supports managed browser overrides ### Via CLI @@ -67,15 +91,19 @@ browser: enabled: false user_data_dir: "~/.ccs/browser/chrome-user-data" devtools_port: 9222 + eval_mode: readonly codex: enabled: true + eval_mode: readonly ``` Notes: - `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile - `claude.devtools_port` is the expected remote debugging port for attach mode +- `claude.eval_mode` controls whether `browser_eval` is disabled, read-only, or read/write for Claude Browser Attach - `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches +- `codex.eval_mode` is stored and surfaced in Browser settings for parity; in Phase 1, `browser_eval` enforcement primarily applies to Claude Browser Attach ## Environment Variable Overrides @@ -86,6 +114,7 @@ CCS still supports environment-variable overrides for backward compatibility. | `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir | | `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory | | `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | +| `CCS_BROWSER_EVAL_MODE` | Explicit `browser_eval` access override for Claude Browser Attach | If an override is active, Browser status surfaces should report that the current session is being managed externally by environment variables. diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 70a0b654..f6a13df7 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -41,6 +41,11 @@ const TOOL_NAVIGATE = 'browser_navigate'; const TOOL_CLICK = 'browser_click'; const TOOL_TYPE = 'browser_type'; 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_NAMES = [ TOOL_SESSION_INFO, TOOL_URL_TITLE, @@ -50,9 +55,30 @@ const TOOL_NAMES = [ TOOL_CLICK, TOOL_TYPE, TOOL_TAKE_SCREENSHOT, + TOOL_WAIT_FOR, + TOOL_EVAL, + TOOL_HOVER, + TOOL_QUERY, + TOOL_TAKE_ELEMENT_SCREENSHOT, ]; +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; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; @@ -221,7 +247,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 +260,11 @@ 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.', + }, }, required: ['selector'], additionalProperties: false, @@ -288,6 +319,145 @@ 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.', + }, + 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.', + }, + }, + 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.', + }, + 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.', + }, + }, + required: ['selector'], + additionalProperties: false, + }, + }, ]; } @@ -521,6 +691,48 @@ 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 getBrowserEvalMode() { + const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim(); + if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') { + return raw; + } + return 'readonly'; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -601,12 +813,16 @@ 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 expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); - const element = document.querySelector(selector); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const matches = Array.from(document.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); @@ -660,7 +876,7 @@ async function handleClick(toolArgs) { })()`; await evaluateExpression(page, expression); - return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`; + return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\nstatus: clicked`; } async function handleType(toolArgs) { @@ -758,6 +974,385 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } +async function getElementDiagnostics(page, selector, nth) { + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const nth = ${nth === undefined ? 'undefined' : String(nth)}; + const matches = Array.from(document.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, + }); + })()`; + return JSON.parse(await evaluateExpression(page, expression)); +} + +async function getScrolledElementState(page, selector) { + const expression = `(() => { + const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); + const element = document.querySelector(selector); + 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 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, rect.left); + const clipY = Math.max(0, rect.top); + const clipRight = Math.min(viewportWidth, rect.right); + const clipBottom = Math.min(viewportHeight, rect.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: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }, + centerPoint: { + x: rect.left + rect.width / 2, + y: rect.top + rect.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)); +} + +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) { + return getElementDiagnostics(page, selector, nth); +} + +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 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) + : await getWaitPageObservation(page); + if (isWaitConditionSatisfied(lastObserved, condition)) { + return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}\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 value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined; + if (value === undefined && result.type !== 'undefined') { + throw new Error('evaluation result is not JSON-serializable'); + } + + return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`; +} + +async function handleHover(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const state = await getScrolledElementState(page, selector); + 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 sendCdpCommand(page, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: state.centerPoint.x, + y: state.centerPoint.y, + button: 'none', + buttons: 0, + pointerType: 'mouse', + }); + + return `pageIndex: ${pageIndex}\nselector: ${selector}\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 fields = parseQueryFields(toolArgs.fields); + const diagnostics = await getElementDiagnostics(page, selector, nth); + return formatQueryResponse(pageIndex, selector, nth, diagnostics, fields); +} + +async function handleElementScreenshot(toolArgs) { + const { page, pageIndex } = await getSelectedPage(toolArgs); + const selector = requireNonEmptyString(toolArgs.selector, 'selector'); + const state = await getScrolledElementState(page, selector); + 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}\nformat: png\ndata: ${data}`; +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -823,6 +1418,46 @@ 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; + } + const { page, pageIndex } = await getSelectedPage(toolArgs); if (toolName === TOOL_URL_TITLE) { diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index bb507ae8..561ae62b 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -34,7 +34,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/src/ccs.ts b/src/ccs.ts index 3cedddbb..43ce870a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -604,7 +604,6 @@ async function main(): Promise { } catch (error) { console.error(fail((error as Error).message)); process.exit(1); - return; } // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) @@ -1089,7 +1088,13 @@ async function main(): Promise { } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); - syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + if (!syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir) && inheritedClaudeConfigDir) { + console.error( + warn( + 'Image Analysis MCP config could not be synced into the inherited Claude instance. This session will continue with the current fallback behavior.' + ) + ); + } if ( browserAttachConfig?.enabled && inheritedClaudeConfigDir && @@ -1269,7 +1274,13 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_BACKEND_ID: '', + CCS_IMAGE_ANALYSIS_MODEL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', }; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED; } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { const ensureServiceResult = await ensureCliproxyService( CLIPROXY_DEFAULT_PORT, @@ -1285,7 +1296,13 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_IMAGE_ANALYSIS_BACKEND_ID: '', + CCS_IMAGE_ANALYSIS_MODEL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', }; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY; + delete imageAnalysisEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED; } } } @@ -1308,6 +1325,7 @@ async function main(): Promise { devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort ? String(browserAttachConfig.devtoolsPort) : undefined, + evalMode: browserAttachConfig.evalMode, })), }; } @@ -1487,6 +1505,7 @@ async function main(): Promise { ? String(browserAttachConfig.devtoolsPort) : undefined, })), + CCS_BROWSER_EVAL_MODE: browserAttachConfig.evalMode, }; Object.assign(envVars, browserRuntimeEnv); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index f3d9f1a9..db2343cb 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -1164,6 +1164,7 @@ export async function execClaudeWithCLIProxy( devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort ? String(browserAttachConfig.devtoolsPort) : undefined, + evalMode: browserAttachConfig.evalMode, })), } : undefined; diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index d9c4ccf6..c172152b 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -50,6 +50,7 @@ function writeClaudeStatus( writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`); writeLine(` User data dir: ${status.effectiveUserDataDir}`); writeLine(` DevTools port: ${status.devtoolsPort}`); + writeLine(` browser_eval access: ${status.evalMode}`); writeLine(` Managed MCP: ${status.managedMcpServerName}`); writeLine(` Managed path: ${status.managedMcpServerPath}`); if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { @@ -69,6 +70,7 @@ function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: Help writeLine(` State: ${status.state}`); writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); writeLine(` Managed server: ${status.serverName}`); + writeLine(` browser_eval access: ${status.evalMode}`); writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); if (status.version) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 50835435..83061660 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -36,6 +36,7 @@ import type { OfficialChannelId, DashboardAuthConfig, BrowserConfig, + BrowserEvalMode, ImageAnalysisConfig, LoggingConfig, CursorConfig, @@ -70,15 +71,25 @@ function normalizeBrowserDevtoolsPort(value: number | undefined): number { return port; } +function normalizeBrowserEvalMode(value: string | undefined): BrowserEvalMode { + if (value === 'disabled' || value === 'readonly' || value === 'readwrite') { + return value; + } + + return DEFAULT_BROWSER_CONFIG.claude.eval_mode; +} + function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { return { claude: { enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + eval_mode: normalizeBrowserEvalMode(config?.claude?.eval_mode), }, codex: { enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, + eval_mode: normalizeBrowserEvalMode(config?.codex?.eval_mode), }, }; } @@ -955,6 +966,7 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push('#'); lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); + lines.push('# eval_mode controls browser_eval access: disabled | readonly | readwrite.'); lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); lines.push('# ----------------------------------------------------------------------------'); lines.push( diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b8b67f4..8c0f816f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -816,6 +816,8 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Browser automation configuration. * Controls Claude browser attach and Codex browser tooling. */ +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + export interface BrowserClaudeConfig { /** Enable Claude browser attach (default: false) */ enabled: boolean; @@ -823,11 +825,15 @@ export interface BrowserClaudeConfig { user_data_dir: string; /** DevTools port used for attach mode (default: 9222) */ devtools_port: number; + /** Eval access mode for browser_eval (default: readonly) */ + eval_mode: BrowserEvalMode; } export interface BrowserCodexConfig { /** Enable Codex browser tooling injection (default: true) */ enabled: boolean; + /** Stored for dashboard/config parity; runtime enforcement is Claude-only in Phase 1 */ + eval_mode: BrowserEvalMode; } export interface BrowserConfig { @@ -840,9 +846,11 @@ export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { enabled: false, user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index e1a9f6fe..e160aa3f 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import type { BrowserConfig } from '../../config/unified-config-types'; +import type { BrowserConfig, BrowserEvalMode } from '../../config/unified-config-types'; import { getCcsDir } from '../config-manager'; import { expandPath } from '../helpers'; @@ -11,6 +11,7 @@ export interface EffectiveClaudeBrowserAttachConfig { overrideActive: boolean; userDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; hasExplicitDevtoolsPort: boolean; } @@ -64,6 +65,7 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: true, userDataDir: override.userDataDir, devtoolsPort: override.devtoolsPort ?? configPort, + evalMode: config.claude.eval_mode, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, }; } @@ -74,6 +76,7 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, + evalMode: config.claude.eval_mode, // Config-backed browser attach always keeps an explicit port so launches // stay aligned with Settings > Browser, even when the effective value is // the default 9222. diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 0ea469d2..9a72a37a 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -25,6 +25,7 @@ export interface ClaudeBrowserStatus { effectiveUserDataDir: string; recommendedUserDataDir: string; devtoolsPort: number; + evalMode: 'disabled' | 'readonly' | 'readwrite'; managedMcpServerName: string; managedMcpServerPath: string; launchCommands: BrowserLaunchCommands; @@ -38,6 +39,7 @@ export interface CodexBrowserStatus { detail: string; nextStep: string; serverName: string; + evalMode: 'disabled' | 'readonly' | 'readwrite'; supportsConfigOverrides: boolean; binaryPath: string | null; version?: string; @@ -68,6 +70,7 @@ async function buildClaudeBrowserStatus( effectiveUserDataDir: effective.userDataDir, recommendedUserDataDir: getRecommendedBrowserUserDataDir(), devtoolsPort: effective.devtoolsPort, + evalMode: effective.evalMode, managedMcpServerName: getBrowserMcpServerName(), managedMcpServerPath: getBrowserMcpServerPath(), launchCommands, @@ -89,6 +92,7 @@ async function buildClaudeBrowserStatus( const runtimeEnv = await resolveBrowserRuntimeEnv({ profileDir: effective.userDataDir, devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined, + evalMode: effective.evalMode, }); return { @@ -142,6 +146,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows nextStep: 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', serverName: 'ccs_browser', + evalMode: browserConfig.codex.eval_mode, supportsConfigOverrides: false, binaryPath: null, }; @@ -159,6 +164,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows : 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', serverName: 'ccs_browser', + evalMode: browserConfig.codex.eval_mode, supportsConfigOverrides, binaryPath: binaryInfo?.path ?? null, version: binaryInfo?.version, @@ -172,6 +178,7 @@ function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrows detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', nextStep: 'Use a Codex-target CCS launch to access browser tools.', serverName: 'ccs_browser', + evalMode: browserConfig.codex.eval_mode, supportsConfigOverrides, binaryPath: binaryInfo.path, version: binaryInfo.version, diff --git a/src/utils/browser/chrome-reuse.ts b/src/utils/browser/chrome-reuse.ts index 976c39ef..02ec96a3 100644 --- a/src/utils/browser/chrome-reuse.ts +++ b/src/utils/browser/chrome-reuse.ts @@ -1,11 +1,13 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import type { BrowserEvalMode } from '../../config/unified-config-types'; import { expandPath } from '../helpers'; export interface BrowserReuseOptions { profileDir?: string; devtoolsPort?: string; + evalMode?: BrowserEvalMode; } export interface BrowserRuntimeEnv { @@ -14,6 +16,7 @@ export interface BrowserRuntimeEnv { CCS_BROWSER_DEVTOOLS_PORT: string; CCS_BROWSER_DEVTOOLS_HTTP_URL: string; CCS_BROWSER_DEVTOOLS_WS_URL: string; + CCS_BROWSER_EVAL_MODE?: string; } const DEVTOOLS_HOST = '127.0.0.1'; @@ -100,6 +103,7 @@ export async function resolveBrowserRuntimeEnv( CCS_BROWSER_DEVTOOLS_PORT: port, CCS_BROWSER_DEVTOOLS_HTTP_URL: httpUrl, CCS_BROWSER_DEVTOOLS_WS_URL: websocketUrl, + ...(options.evalMode ? { CCS_BROWSER_EVAL_MODE: options.evalMode } : {}), }; } diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 14d8bdc3..f248f464 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -12,9 +12,11 @@ interface BrowserRouteBody { enabled?: boolean; userDataDir?: string; devtoolsPort?: number; + evalMode?: 'disabled' | 'readonly' | 'readwrite'; }; codex?: { enabled?: boolean; + evalMode?: 'disabled' | 'readonly' | 'readwrite'; }; } @@ -22,6 +24,10 @@ function isValidDevtoolsPort(value: number): boolean { return Number.isInteger(value) && value >= 1 && value <= 65535; } +function isValidEvalMode(value: string): value is 'disabled' | 'readonly' | 'readwrite' { + return value === 'disabled' || value === 'readonly' || value === 'readwrite'; +} + router.use((req: Request, res: Response, next) => { if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) { next(); @@ -86,10 +92,22 @@ router.put('/', async (req: Request, res: Response): Promise => { }); return; } + if (claude?.evalMode !== undefined && !isValidEvalMode(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; } + if (codex?.evalMode !== undefined && !isValidEvalMode(codex.evalMode)) { + res.status(400).json({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + return; + } try { const current = getBrowserConfig(); @@ -101,9 +119,11 @@ router.put('/', async (req: Request, res: Response): Promise => { enabled: claude?.enabled ?? current.claude.enabled, 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, + eval_mode: codex?.evalMode ?? current.codex.eval_mode, }, }; }); @@ -128,9 +148,11 @@ function toBrowserRouteConfig(config: ReturnType) { enabled: config.claude.enabled, userDataDir: config.claude.user_data_dir, devtoolsPort: config.claude.devtools_port, + evalMode: config.claude.eval_mode, }, codex: { enabled: config.codex.enabled, + evalMode: config.codex.eval_mode, }, }; } diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts index c7c55412..f1d261d8 100644 --- a/tests/unit/commands/browser-command.test.ts +++ b/tests/unit/commands/browser-command.test.ts @@ -37,6 +37,7 @@ describe('browser command', () => { effectiveUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile', devtoolsPort: 9222, + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', managedMcpServerPath: '/tmp/ccs-browser-server.cjs', launchCommands: { @@ -50,6 +51,7 @@ describe('browser command', () => { CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + CCS_BROWSER_EVAL_MODE: 'readonly', }, }, codex: { @@ -59,6 +61,7 @@ describe('browser command', () => { detail: 'CCS can inject the managed Playwright MCP overrides.', nextStep: 'Use a Codex-target launch.', serverName: 'ccs_browser', + evalMode: 'readonly', supportsConfigOverrides: true, binaryPath: '/usr/local/bin/codex', version: 'codex-cli 0.120.0', @@ -75,6 +78,7 @@ describe('browser command', () => { ); expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true); expect(rendered.includes('Managed server: ccs_browser')).toBe(true); + expect(rendered.includes('browser_eval access: readonly')).toBe(true); expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true); } finally { statusSpy.mockRestore(); @@ -100,6 +104,7 @@ describe('browser command', () => { effectiveUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile', devtoolsPort: 9444, + evalMode: 'readwrite', managedMcpServerName: 'ccs-browser', managedMcpServerPath: '/tmp/ccs-browser-server.cjs', launchCommands, @@ -111,6 +116,7 @@ describe('browser command', () => { detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', serverName: 'ccs_browser', + evalMode: 'disabled', supportsConfigOverrides: false, binaryPath: '/usr/local/bin/codex', version: 'codex-cli 0.70.0', @@ -150,6 +156,7 @@ describe('browser command', () => { effectiveUserDataDir: '/tmp/browser-profile', recommendedUserDataDir: '/tmp/browser-profile', devtoolsPort: 9222, + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', managedMcpServerPath: '/tmp/ccs-browser-server.cjs', launchCommands: { @@ -167,6 +174,7 @@ describe('browser command', () => { detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', serverName: 'ccs_browser', + evalMode: 'readonly', supportsConfigOverrides: false, binaryPath: null, }, diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 6cab5f48..00a154cd 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -8,6 +8,76 @@ 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; +}; + +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 MockEvalPlan = Record< + string, + { + result?: unknown; + error?: string; + nonSerializable?: boolean; + } +>; + type MockPageState = { id: string; title: string; @@ -16,25 +86,30 @@ type MockPageState = { 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; - } - >; + click?: Record; + hover?: Record; + query?: Record; + wait?: MockWaitPlan; + eval?: MockEvalPlan; 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, @@ -153,6 +228,46 @@ function parseJsonArgument(expression: string, key: string): string | undefined 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 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 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 createMockBrowser(pagesInput: MockPageState[]) { let tempDir = ''; let httpServer: http.Server | null = null; @@ -250,23 +365,76 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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); + for (const hoverPlan of Object.values(page.hover || {})) { + if (type === 'mouseMoved') { + hoverPlan.lastMouseMove = { x, y }; + } + } + reply({}); + return; + } + if (message.method !== 'Runtime.evaluate') { return; } const expression = String(message.params?.expression || ''); + if (page.eval?.[expression]) { + const evalPlan = page.eval[expression]; + if (evalPlan.error) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: evalPlan.error } }, + }) + ); + return; + } + if (evalPlan.nonSerializable) { + socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); + return; + } + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { type: 'object', value: evalPlan.result } }, + }) + ); + return; + } + if (expression.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 || '' } }); + reply({ result: { type: 'string', value: shiftPageText(page) } }); return; } @@ -288,7 +456,9 @@ function createMockBrowser(pagesInput: MockPageState[]) { if (expression.includes('scrollIntoView') && expression.includes('.click()')) { 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; @@ -308,47 +478,51 @@ function createMockBrowser(pagesInput: MockPageState[]) { (index) => catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd ); - if (!clickPlan) { - replyError(`element not found for selector: ${selector}`); + if (!resolvedClickPlan) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); return; } - if (clickPlan.detached && expression.includes('element.isConnected')) { + 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 (clickPlan.disabled) { + if (resolvedClickPlan.disabled) { replyError(`element is disabled for selector: ${selector}`); return; } - if (clickPlan.hidden && expression.includes('getBoundingClientRect')) { + if (resolvedClickPlan.hidden && expression.includes('getBoundingClientRect')) { replyError(`element is hidden or not interactable for selector: ${selector}`); return; } - if (clickPlan.requireMouseSequence && !attemptedMouseSequence) { + if (resolvedClickPlan.requireMouseSequence && !attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; } - if (clickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { + if (resolvedClickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { replyError(`synthetic click event forbidden for selector: ${selector}`); return; } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !readsDispatchResult) { + if ((resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !readsDispatchResult) { replyError(`dispatch result must be checked for selector: ${selector}`); return; } - if ((clickPlan.cancelMouseDown || clickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { + if ((resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { replyError(`native click must be gated for selector: ${selector}`); return; } - if (clickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { + if (resolvedClickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { replyError(`connected state must be rechecked for selector: ${selector}`); return; } - if (clickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { + if (resolvedClickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { replyError(`native click required for selector: ${selector}`); return; } - if (clickPlan.mouseSequenceError) { + if (resolvedClickPlan.mouseSequenceError) { if (!attemptedMouseSequence) { replyError(`mousedown/mouseup required for selector: ${selector}`); return; @@ -357,11 +531,166 @@ function createMockBrowser(pagesInput: MockPageState[]) { reply({ result: { type: 'string', value: 'ok' } }); return; } - replyError(clickPlan.mouseSequenceError); + replyError(resolvedClickPlan.mouseSequenceError); return; } - if (clickPlan.error) { - replyError(clickPlan.error); + if (resolvedClickPlan.error) { + replyError(resolvedClickPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + 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 queryPlan = shiftSelectorSnapshot(page, selector); + if ( + page.screenshot?.requireScrolledMeasurement && + selector in (page.query || {}) && + !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 = 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 = 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' } }); @@ -508,12 +837,22 @@ describe('ccs-browser MCP server', () => { 'browser_click', 'browser_type', 'browser_take_screenshot', + 'browser_wait_for', + 'browser_eval', + 'browser_hover', + 'browser_query', + 'browser_take_element_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()'); + 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', @@ -789,7 +1128,7 @@ describe('ccs-browser MCP server', () => { 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'); + 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); @@ -800,6 +1139,41 @@ describe('ccs-browser MCP server', () => { 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( [ @@ -1014,6 +1388,780 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #fallback'); }); + it('requires real mouse movement for hover-only targets and reports hover failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Hover Page', + currentUrl: 'https://example.com/', + hover: { + '.draft-card': { requireCdpMouseMove: true }, + '.missing-bounds': { zeroSized: true }, + }, + query: { + '.draft-card': { + exists: true, + connected: true, + rect: { + x: 100, + y: 40, + width: 120, + height: 48, + top: 40, + right: 220, + bottom: 88, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-bounds': { + exists: true, + connected: true, + rect: { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.draft-card' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.missing-bounds' }, + }, + }, + ] + ); + + const hoverResponse = responses.find((message) => message.id === 2); + expect((hoverResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(hoverResponse)).toContain('status: hovered'); + expect(getResponseText(hoverResponse)).toContain('selector: .draft-card'); + + const hoverError = responses.find((message) => message.id === 3); + expect((hoverError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hoverError)).toContain( + 'element is hidden or not interactable for selector: .missing-bounds' + ); + }); + + it('queries element diagnostics and validates query fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: 123, + y: 45, + width: 48, + height: 24, + top: 45, + right: 171, + bottom: 69, + left: 123, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-button': { + exists: false, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.edit-button', + fields: ['display', 'visibility', 'opacity'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: 'display' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: ['displayMode'] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain('exists: true'); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'boundingClientRect: {"x":123' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain('display: block'); + expect(getResponseText(responses.find((message) => message.id === 3))).not.toContain('innerText:'); + + const missingResponse = responses.find((message) => message.id === 4); + expect((missingResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(missingResponse)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 5))).toContain( + 'Browser MCP failed: fields must be an array of strings' + ); + expect(getResponseText(responses.find((message) => message.id === 6))).toContain( + 'Browser MCP failed: unknown query field: displayMode' + ); + }); + + it('reports count, nth-aware fields, href, and onclick for multi-match selectors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + href: 'https://example.com/open', + onclick: 'openCard()', + }, + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '0.95', + href: 'https://example.com/archive', + onclick: 'archiveCard()', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 1, + fields: ['count', 'exists', 'innerText', 'href', 'onclick'], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 30)); + expect(text).toContain('count: 2'); + expect(text).toContain('exists: true'); + expect(text).toContain('innerText: Archive'); + expect(text).toContain('href: https://example.com/archive'); + expect(text).toContain('onclick: archiveCard()'); + expect(text).toContain('nth: 1'); + }); + + it('keeps count and exists available when nth is out of range, but rejects target fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'exists'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 32, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'innerText'], + }, + }, + }, + ] + ); + + const countOnly = responses.find((message) => message.id === 31); + expect((countOnly?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(countOnly)).toContain('count: 1'); + expect(getResponseText(countOnly)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 32))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .hover-action' + ); + }); + + it('waits for selector visibility with opacity threshold', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Page', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + { exists: false }, + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 40, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 40))).toContain('status: satisfied'); + }); + + it('waits for page text includes and returns timeout summaries', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Timeout', + currentUrl: 'https://example.com/', + wait: { + pageTextSequence: ['loading', 'loading', 'loaded archive menu'], + selectorSnapshots: { + '.hover-action': [ + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.2', + rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'archive menu' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 30, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 41))).toContain('status: satisfied'); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'Browser MCP failed: wait condition timed out' + ); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain('opacity=0.2'); + }); + + it('waits for selector text includes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Text', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [{ exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }], + [{ exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1' }], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 45, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'Archive' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 45))).toContain('status: satisfied'); + }); + + it('lets nth become valid later during polling and rejects unsupported page-level wait kinds', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Nth', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [{ exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }], + [ + { exists: true, connected: true, innerText: 'Open', textContent: 'Open', display: 'block', visibility: 'visible', opacity: '1' }, + { exists: true, connected: true, innerText: 'Archive', textContent: 'Archive', display: 'block', visibility: 'visible', opacity: '1', rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 } }, + ], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 43, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + nth: 1, + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 44, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 100, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 43))).toContain('status: satisfied'); + expect(getResponseText(responses.find((message) => message.id === 44))).toContain( + 'Browser MCP failed: page-level wait only supports text conditions in Phase 1' + ); + }); + + it('defaults browser_eval to readonly and enforces readwrite gating', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'JSON.stringify({ hovered: true, label: "Archive" })': { + result: { hovered: true, label: 'Archive' }, + }, + 'document.body.dataset.test = "1"': { + error: 'EvalError: Possible side-effect in debug-evaluate', + }, + 'throw new Error("boom")': { + error: 'boom', + }, + 'window': { + nonSerializable: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 50, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'JSON.stringify({ hovered: true, label: "Archive" })' }, + }, + }, + { + jsonrpc: '2.0', + id: 51, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"' }, + }, + }, + { + jsonrpc: '2.0', + id: 52, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + { + jsonrpc: '2.0', + id: 53, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'throw new Error("boom")' }, + }, + }, + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'window' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readonly' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 50))).toContain('mode: readonly'); + expect(getResponseText(responses.find((message) => message.id === 50))).toContain( + 'value: {"hovered":true,"label":"Archive"}' + ); + expect(getResponseText(responses.find((message) => message.id === 51))).toContain( + 'Browser MCP failed: EvalError: Possible side-effect in debug-evaluate' + ); + expect(getResponseText(responses.find((message) => message.id === 52))).toContain( + 'Browser MCP failed: browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=readonly' + ); + expect(getResponseText(responses.find((message) => message.id === 53))).toContain('Browser MCP failed: boom'); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'Browser MCP failed: evaluation result is not JSON-serializable' + ); + }); + + it('allows readwrite browser_eval when configured', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'document.body.dataset.test = "1"': { + result: 'ok', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readwrite' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 54))).toContain('mode: readwrite'); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain('value: "ok"'); + }); + + it('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' + ); + }); + it('captures screenshots and reports empty payload failures', async () => { const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; const responses = await runMcpRequests( diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index bfdb1212..6cac31ce 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -182,7 +182,7 @@ process.exit(0); ['-c', 'model="gpt-5"', '--version'], [ '-c', - `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`, '-c', `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, '-c', @@ -207,9 +207,11 @@ process.exit(0); enabled: false, user_data_dir: '', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: false, + eval_mode: 'readonly', }, }; }); @@ -254,7 +256,7 @@ process.exit(0); ['-c', 'model="gpt-5"', '--version'], [ '-c', - `mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`, + `mcp_servers.ccs_browser.command=${JSON.stringify('npx')}`, '-c', `mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`, '-c', diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 93dd4552..f49d397d 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -111,6 +111,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" + printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE" } > "${claudeEnvLogPath}" exit 0 `, @@ -126,6 +127,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(() => { @@ -149,7 +157,7 @@ exit 0 fs.writeFileSync(delayedPortFile, '43123', 'utf8'); }, 50); - await expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123'); + expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123'); }); it('ignores stale default Chrome DevTools metadata unless browser reuse is explicitly configured', () => { @@ -245,6 +253,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); + expect(launchedEnv).toContain('evalMode=readonly'); }); it('uses config-backed browser attach settings when env overrides are absent', async () => { @@ -298,9 +307,11 @@ server.listen(0, '127.0.0.1', () => { enabled: true, user_data_dir: browserProfileDir, devtools_port: Number.parseInt(port, 10), + eval_mode: 'readwrite', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; }); @@ -317,6 +328,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target'); + expect(launchedEnv).toContain('evalMode=readwrite'); } finally { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index b94846fc..416f77f6 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -87,6 +87,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT" printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL" printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL" + printf "evalMode=%s\n" "$CCS_BROWSER_EVAL_MODE" } > "${claudeEnvLogPath}" exit 0 `, @@ -102,6 +103,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(() => { @@ -196,6 +204,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); + expect(launchedEnv).toContain('evalMode=readonly'); }); it('uses config-backed browser attach settings for settings-profile launches', async () => { @@ -255,9 +264,11 @@ server.listen(0, '127.0.0.1', () => { enabled: true, user_data_dir: browserProfileDir, devtools_port: Number.parseInt(port, 10), + eval_mode: 'disabled', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; }); @@ -274,6 +285,7 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); expect(launchedEnv).toContain(`port=${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target'); + expect(launchedEnv).toContain('evalMode=disabled'); } finally { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; 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..110d1123 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -113,6 +113,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 +174,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 +257,8 @@ 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('pins direct settings image analysis to the current local CLIProxy auth token', () => { @@ -273,8 +284,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/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index a15cd461..6e73e89a 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -72,6 +72,7 @@ describe('browser status', () => { source: 'config', effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, + evalMode: 'readonly', managedMcpServerName: 'ccs-browser', }); expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); @@ -79,6 +80,7 @@ describe('browser status', () => { enabled: true, state: 'enabled', serverName: 'ccs_browser', + evalMode: 'readonly', supportsConfigOverrides: true, }); } finally { @@ -93,9 +95,11 @@ describe('browser status', () => { enabled: true, user_data_dir: '/config-browser', devtools_port: 9333, + eval_mode: 'readwrite', }, codex: { enabled: true, + eval_mode: 'disabled', }, }; }); @@ -108,6 +112,7 @@ describe('browser status', () => { CCS_BROWSER_DEVTOOLS_PORT: '9444', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + CCS_BROWSER_EVAL_MODE: 'readwrite', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', @@ -125,8 +130,11 @@ describe('browser status', () => { source: 'CCS_BROWSER_USER_DATA_DIR', effectiveUserDataDir: '/env-browser', devtoolsPort: 9444, + evalMode: 'readwrite', }); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); + expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readwrite'); + expect(status.codex.evalMode).toBe('disabled'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); @@ -140,9 +148,11 @@ describe('browser status', () => { enabled: true, user_data_dir: '/tmp/browser-profile', devtools_port: 9222, + eval_mode: 'readonly', }, codex: { enabled: true, + eval_mode: 'readonly', }, }; }); @@ -178,6 +188,7 @@ describe('browser status', () => { CCS_BROWSER_DEVTOOLS_PORT: '50123', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy', + CCS_BROWSER_EVAL_MODE: 'readonly', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', @@ -192,8 +203,10 @@ describe('browser status', () => { expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ profileDir: '/legacy-browser', devtoolsPort: undefined, + evalMode: 'readonly', }); expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); + expect(status.claude.runtimeEnv?.CCS_BROWSER_EVAL_MODE).toBe('readonly'); } finally { runtimeSpy.mockRestore(); codexSpy.mockRestore(); @@ -207,9 +220,11 @@ describe('browser status', () => { enabled: true, user_data_dir: '/tmp/config-browser', devtools_port: 9222, + eval_mode: 'disabled', }, codex: { enabled: true, + eval_mode: 'readwrite', }, }; }); @@ -220,6 +235,7 @@ describe('browser status', () => { CCS_BROWSER_DEVTOOLS_PORT: '9222', CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/config', + CCS_BROWSER_EVAL_MODE: 'disabled', }); const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ path: '/usr/local/bin/codex', @@ -234,6 +250,7 @@ describe('browser status', () => { expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ profileDir: '/tmp/config-browser', devtoolsPort: '9222', + evalMode: 'disabled', }); } finally { runtimeSpy.mockRestore(); 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 bc6e0305..bb1cccea 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 originalBrowserDevtoolsPort: string | undefined; + let originalBrowserDevtoolsHost: 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; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST; + 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_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; forcedRemoteAddress = '127.0.0.1'; }); @@ -72,6 +93,48 @@ 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 (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + + if (originalBrowserDevtoolsHost !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_HOST; + } + + 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 }); }); @@ -95,9 +158,11 @@ describe('browser routes', () => { enabled: false, userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9222, + evalMode: 'readonly', }, codex: { enabled: true, + evalMode: 'readonly', }, }); expect(payload.status.claude).toMatchObject({ @@ -119,9 +184,11 @@ describe('browser routes', () => { enabled: true, userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { enabled: false, + evalMode: 'disabled', }, }), }); @@ -133,9 +200,11 @@ describe('browser routes', () => { enabled: true, userDataDir: '/tmp/ccs-browser', devtoolsPort: 9333, + evalMode: 'readwrite', }, codex: { enabled: false, + evalMode: 'disabled', }, }); @@ -145,9 +214,11 @@ describe('browser routes', () => { enabled: true, user_data_dir: '/tmp/ccs-browser', devtools_port: 9333, + eval_mode: 'readwrite', }, codex: { enabled: false, + eval_mode: 'disabled', }, }); }); @@ -183,6 +254,7 @@ describe('browser routes', () => { enabled: true, userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtoolsPort: 9333, + evalMode: 'readonly', }); const config = loadOrCreateUnifiedConfig(); @@ -191,6 +263,7 @@ describe('browser routes', () => { enabled: true, user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtools_port: 9333, + eval_mode: 'readonly', }, }); }); @@ -211,4 +284,36 @@ describe('browser routes', () => { error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', }); }); + + it('rejects invalid eval modes at the route boundary', async () => { + const claudeResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + evalMode: 'dangerous', + }, + }), + }); + + expect(claudeResponse.status).toBe(400); + expect(await claudeResponse.json()).toEqual({ + error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + + const codexResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + codex: { + evalMode: 'dangerous', + }, + }), + }); + + expect(codexResponse.status).toBe(400); + expect(await codexResponse.json()).toEqual({ + error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.', + }); + }); }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 0a8bc075..f7219616 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -234,14 +234,18 @@ export interface UpdateImageAnalysisSettingsPayload { profileBackends?: Record; } +export type BrowserEvalMode = 'disabled' | 'readonly' | 'readwrite'; + export interface BrowserSettingsConfig { claude: { enabled: boolean; userDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; }; codex: { enabled: boolean; + evalMode: BrowserEvalMode; }; } @@ -262,6 +266,7 @@ export interface ClaudeBrowserStatus { effectiveUserDataDir: string; recommendedUserDataDir: string; devtoolsPort: number; + evalMode: BrowserEvalMode; managedMcpServerName: string; managedMcpServerPath: string; launchCommands: BrowserLaunchCommands; @@ -275,6 +280,7 @@ export interface CodexBrowserStatus { 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 5329df3a..e794e8dc 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2435,6 +2435,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', @@ -2461,6 +2469,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', @@ -2480,6 +2490,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.', }, }, }, @@ -4850,6 +4862,14 @@ const resources = { nextStep: '下一步', technicalDetails: '技术细节', diagnostics: '诊断信息', + evalMode: { + label: 'browser_eval 权限', + options: { + disabled: '禁用', + readonly: '只读', + readwrite: '可读写', + }, + }, actions: { saveClaude: '保存 Claude 设置', saveCodex: '保存 Codex 设置', @@ -4873,6 +4893,8 @@ const resources = { effectivePath: '当前生效的 attach 路径', recommendedPath: '推荐路径', managedRuntime: '托管浏览器运行时', + evalModeHint: + '控制 Claude Browser Attach 中 browser_eval 的权限。只读仅允许读取,读写还允许页面侧修改。', overrideMessage: '当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。', launchGuidance: '启动指引', @@ -4889,6 +4911,8 @@ const resources = { overrideUnsupported: '不支持', binary: '检测到的 Codex 可执行文件', notDetected: '未检测到', + evalModeHint: + '为保持 Browser 设置页一致性而保存该配置。Phase 1 中,browser_eval 的实际限制主要作用于 Claude Browser Attach。', }, }, }, @@ -7367,6 +7391,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', @@ -7394,6 +7426,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', @@ -7413,6 +7447,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.', }, }, }, @@ -9791,6 +9827,14 @@ const resources = { nextStep: '次の手順', technicalDetails: '技術的な詳細', diagnostics: '診断情報', + evalMode: { + label: 'browser_eval 権限', + options: { + disabled: '無効', + readonly: '読み取り専用', + readwrite: '読み書き可', + }, + }, actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', @@ -9818,6 +9862,8 @@ const resources = { effectivePath: '現在有効な attach パス', recommendedPath: '推奨パス', managedRuntime: '管理済みブラウザランタイム', + evalModeHint: + 'Claude Browser Attach における browser_eval の権限を制御します。読み取り専用は参照のみ、読み書き可はページ側の変更も許可します。', overrideMessage: '{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。', launchGuidance: '起動ガイダンス', @@ -9837,6 +9883,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..3ff14bd4 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -4,6 +4,7 @@ */ import type { + BrowserEvalMode, BrowserSettingsConfig, BrowserStatusPayload, CliproxyServerConfig, @@ -198,7 +199,7 @@ export interface ThinkingConfig { // === Re-exports from api-client === -export type { CliproxyServerConfig, RemoteProxyStatus }; +export type { BrowserEvalMode, CliproxyServerConfig, RemoteProxyStatus }; export type BrowserConfig = BrowserSettingsConfig; export type BrowserStatus = BrowserStatusPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload; From cf2375e169d808964a9c80077a657712630e6d35 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 01:55:16 +0800 Subject: [PATCH 02/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E4=B8=89=E9=98=B6=E6=AE=B5=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 iframe/shadow 作用域查询与页面事件观察能力,并同步测试与文档,确保 Phase 3 以可验证增量独立收口。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 27 +- lib/mcp/ccs-browser-server.cjs | 629 +++++++++++-- .../unit/hooks/ccs-browser-mcp-server.test.ts | 844 +++++++++++++++--- 3 files changed, 1305 insertions(+), 195 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 6ffe5d20..f4a72d3d 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -20,12 +20,13 @@ that already has useful authenticated state. Claude Browser Attach requires a browser launched in attach mode with remote debugging enabled. A recent Chrome update alone is not sufficient. -The managed `ccs-browser` runtime currently exposes four tool groups: +The managed `ccs-browser` runtime currently exposes five tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` - **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_take_screenshot` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` +- **Event observation**: `browser_wait_for_event` Notable Phase 1 capability details: @@ -34,6 +35,12 @@ Notable Phase 1 capability details: - `browser_wait_for` can wait on page text or selector state before the next step runs - `browser_eval` is gated by `browser.claude.eval_mode` and supports `disabled`, `readonly`, or `readwrite`; readonly mode uses side-effect-blocked evaluation and may reject expressions that could mutate page state +Phase 3 capability details: + +- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` to resolve targets inside a specific same-origin iframe +- the same scoped-selector tools accept optional `pierceShadow: true` to search open shadow roots beneath the selected root +- `browser_wait_for_event` observes typed page or browser events for dialogs, navigation, requests, and downloads + A common hover-debug workflow is: 1. call `browser_hover` to move the browser pointer onto the card or trigger @@ -42,6 +49,24 @@ A common hover-debug workflow is: 4. call `browser_take_element_screenshot` to confirm the revealed state 5. call `browser_eval` in read-only mode when you need page-side inspection that the structured tools do not expose directly +Scoped selector notes: + +- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible +- the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal +- closed shadow roots, frame-index routing, request interception, and download acceptance controls are still out of scope + +Example event wait: + +```json +{ + "name": "browser_wait_for_event", + "arguments": { + "event": { "kind": "navigation", "urlIncludes": "/checkout" }, + "timeoutMs": 2000 + } +} +``` + ### Codex Browser Tools Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index f6a13df7..759cf306 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. @@ -46,6 +49,7 @@ 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, @@ -60,6 +64,7 @@ const TOOL_NAMES = [ TOOL_HOVER, TOOL_QUERY, TOOL_TAKE_ELEMENT_SCREENSHOT, + TOOL_WAIT_FOR_EVENT, ]; const SUPPORTED_QUERY_FIELDS = [ 'exists', @@ -265,6 +270,14 @@ function getTools() { 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.', + }, }, required: ['selector'], additionalProperties: false, @@ -340,6 +353,14 @@ function getTools() { 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, @@ -401,6 +422,14 @@ function getTools() { 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, @@ -427,6 +456,14 @@ function getTools() { 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' }, @@ -453,11 +490,44 @@ function getTools() { 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, + }, + }, ]; } @@ -733,6 +803,177 @@ function getBrowserEvalMode() { return 'readonly'; } +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 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)); } @@ -815,11 +1056,52 @@ async function handleClick(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 expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); const nth = ${nth === undefined ? 'undefined' : String(nth)}; - const matches = Array.from(document.querySelectorAll(selector)); + 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 element = matches[nth ?? 0]; if (!element) { throw new Error('element index ' + (nth ?? 0) + ' is out of range for selector: ' + selector); @@ -876,7 +1158,7 @@ async function handleClick(toolArgs) { })()`; await evaluateExpression(page, expression); - return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\nstatus: clicked`; + return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: clicked`; } async function handleType(toolArgs) { @@ -974,63 +1256,57 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } -async function getElementDiagnostics(page, selector, nth) { - const expression = `(() => { - const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); - const nth = ${nth === undefined ? 'undefined' : String(nth)}; - const matches = Array.from(document.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, - }); - })()`; - return JSON.parse(await evaluateExpression(page, expression)); +async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) { + return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); } -async function getScrolledElementState(page, selector) { +async function getScrolledElementState(page, selector, frameSelector = '', pierceShadow = false) { const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); - const element = document.querySelector(selector); + 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 element = matches[0]; if (!element) { return JSON.stringify({ exists: false }); } @@ -1039,13 +1315,23 @@ async function getScrolledElementState(page, selector) { } 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, rect.left); - const clipY = Math.max(0, rect.top); - const clipRight = Math.min(viewportWidth, rect.right); - const clipBottom = Math.min(viewportHeight, rect.bottom); + 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, @@ -1057,19 +1343,10 @@ async function getScrolledElementState(page, selector) { style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0, - 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, - }, + boundingClientRect: absoluteRect, centerPoint: { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, + x: absoluteRect.left + absoluteRect.width / 2, + y: absoluteRect.top + absoluteRect.height / 2, }, visibleClip: { x: clipX, @@ -1218,14 +1495,16 @@ async function getWaitPageObservation(page) { return { text }; } -async function getWaitSelectorObservation(page, selector, nth) { - return getElementDiagnostics(page, selector, nth); +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, @@ -1238,10 +1517,10 @@ async function handleWaitFor(toolArgs) { while (Date.now() <= deadline) { lastObserved = selector - ? await getWaitSelectorObservation(page, selector, nth) + ? await getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) : await getWaitPageObservation(page); if (isWaitConditionSatisfied(lastObserved, condition)) { - return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}\nstatus: satisfied`; + return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: satisfied`; } if (Date.now() + pollIntervalMs > deadline) { break; @@ -1291,7 +1570,9 @@ async function handleEval(toolArgs) { async function handleHover(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); - const state = await getScrolledElementState(page, 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}`); } @@ -1311,22 +1592,26 @@ async function handleHover(toolArgs) { pointerType: 'mouse', }); - return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: hovered`; + 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); + 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 state = await getScrolledElementState(page, 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}`); } @@ -1350,7 +1635,177 @@ async function handleElementScreenshot(toolArgs) { throw new Error('screenshot capture failed'); } - return `pageIndex: ${pageIndex}\nselector: ${selector}\nformat: png\ndata: ${data}`; + 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 waitForBrowserDownloadEvent(timeoutMs, event) { + 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'); + } + + 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 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(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)}`; } async function handleToolCall(message) { @@ -1458,6 +1913,14 @@ async function handleToolCall(message) { 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) { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 00a154cd..9afc8d3e 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; -import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import * as http from 'node:http'; import { WebSocketServer } from 'ws'; import { tmpdir } from 'node:os'; @@ -69,6 +69,24 @@ type MockWaitPlan = { pageTextSequence?: string[]; }; +type MockPageEventPlan = { + dialogs?: Array<{ type: string; message: string }>; + navigations?: Array<{ url: string; parentId?: string }>; + requests?: Array<{ url: string; method: string }>; + downloads?: Array<{ url: string; suggestedFilename: string }>; +}; + +type MockFrameState = { + selector: string; + query?: Record; + visibleText?: string; +}; + +type MockShadowRootState = { + hostSelector: string; + query?: Record; +}; + type MockEvalPlan = Record< string, { @@ -91,6 +109,9 @@ type MockPageState = { query?: Record; wait?: MockWaitPlan; eval?: MockEvalPlan; + frames?: MockFrameState[]; + shadowRoots?: MockShadowRootState[]; + events?: MockPageEventPlan; screenshot?: { expectedClip?: { x: number; @@ -130,6 +151,7 @@ const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server. type RunMcpRequestsOptions = { serverPath?: string; childEnv?: NodeJS.ProcessEnv; + responseTimeoutMs?: number; }; function encodeMessage(message: unknown): string { @@ -138,12 +160,40 @@ function encodeMessage(message: unknown): string { function collectResponses( child: ReturnType, - expectedCount: number + expectedCount: number, + timeoutMs = 7000 ): Promise>> { return new Promise((resolve, reject) => { let buffer = Buffer.alloc(0); + let stderrBuffer = ''; + let settled = false; const responses: Array> = []; - const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000); + 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) { @@ -160,16 +210,14 @@ function collectResponses( responses.push(JSON.parse(body) as Record); if (responses.length >= expectedCount) { - clearTimeout(timer); - resolve(responses); + finish(); return; } } } if (!child.stdout) { - clearTimeout(timer); - reject(new Error('MCP child stdout is unavailable')); + fail(new Error('MCP child stdout is unavailable')); return; } @@ -178,14 +226,29 @@ function collectResponses( try { tryParse(); } catch (error) { - clearTimeout(timer); - reject(error); + fail(error instanceof Error ? error : new Error(String(error))); } }); + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrBuffer += chunk.toString(); + }); + child.on('error', (error) => { - clearTimeout(timer); - reject(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}` + ) + ); }); }); } @@ -236,7 +299,10 @@ function parseNumberArgument(expression: string, key: string): number | undefine return Number.parseInt(match[1], 10); } -function pickMockMatch(plan: T | T[] | undefined, nth = 0): { +function pickMockMatch( + plan: T | T[] | undefined, + nth = 0 +): { count: number; target: T | undefined; } { @@ -257,6 +323,14 @@ function shiftSelectorSnapshot(page: MockPageState, selector: string): MockQuery 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 shiftPageText(page: MockPageState): string { const queue = page.wait?.pageTextSequence; if (!queue || queue.length === 0) { @@ -268,10 +342,24 @@ function shiftPageText(page: MockPageState): string { 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(); for (const [index, page] of pagesInput.entries()) { @@ -294,15 +382,22 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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]) => ({ + 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}`, - })) - ) + })), + { + id: 'browser-target', + type: 'browser', + title: 'Browser', + url: '', + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${browserSocketPath || '/devtools/browser'}`, + }, + ]) ); return; } @@ -321,8 +416,30 @@ function createMockBrowser(pagesInput: MockPageState[]) { }); }); + browserSocketPath = '/devtools/browser'; wsServer = new WebSocketServer({ server: httpServer as http.Server }); wsServer.on('connection', (socket, request) => { + if ((request.url || '') === browserSocketPath) { + const downloadPage = pagesInput.find( + (candidate) => (candidate.events?.downloads?.length || 0) > 0 + ); + if (downloadPage?.events?.downloads?.[0]) { + const download = downloadPage.events.downloads[0]; + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadWillBegin', + params: { + url: download.url, + suggestedFilename: download.suggestedFilename, + }, + }) + ); + }, 10); + } + return; + } + const page = pageStates.get(request.url || ''); if (!page) { socket.close(); @@ -341,7 +458,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { } function replyError(errorText: string): void { - socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } })); + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { subtype: 'error', description: errorText } }, + }) + ); } if (message.method === 'Page.navigate') { @@ -364,7 +486,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { reply({ data: '' }); return; } - page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true; + page.screenshot.lastCaptureBeyondViewport = + message.params?.captureBeyondViewport === true; const clip = message.params?.clip && typeof message.params.clip === 'object' ? (message.params.clip as Record) @@ -398,6 +521,51 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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 !== 'Runtime.evaluate') { return; } @@ -429,7 +597,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (expression.includes('document.title') && expression.includes('location.href')) { - reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } }); + reply({ + result: { + type: 'string', + value: JSON.stringify({ title: page.title, url: page.currentUrl }), + }, + }); return; } @@ -464,19 +637,30 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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 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 + (index) => + catchIndex !== -1 && + catchBlockEnd !== -1 && + index > catchIndex && + index < catchBlockEnd ); const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( (index) => - catchIndex === -1 || catchBlockEnd === -1 || index < catchIndex || index > catchBlockEnd + catchIndex === -1 || + catchBlockEnd === -1 || + index < catchIndex || + index > catchBlockEnd ); if (!resolvedClickPlan) { replyError(`element index ${nth} is out of range for selector: ${selector}`); @@ -506,11 +690,17 @@ function createMockBrowser(pagesInput: MockPageState[]) { replyError(`synthetic click event forbidden for selector: ${selector}`); return; } - if ((resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !readsDispatchResult) { + if ( + (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && + !readsDispatchResult + ) { replyError(`dispatch result must be checked for selector: ${selector}`); return; } - if ((resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && !gatesNativeClickOnDispatchResult) { + if ( + (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && + !gatesNativeClickOnDispatchResult + ) { replyError(`native click must be gated for selector: ${selector}`); return; } @@ -551,10 +741,32 @@ function createMockBrowser(pagesInput: MockPageState[]) { ) { const selector = parseJsonArgument(expression, 'selector') || ''; const nth = parseNumberArgument(expression, 'nth'); - const queryPlan = shiftSelectorSnapshot(page, selector); + 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 || {}) && + (selector in (page.query || {}) || Boolean(scopedQuery)) && !expression.includes('scrollIntoView') ) { replyError(`scrollIntoView required for selector: ${selector}`); @@ -572,7 +784,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { result: { type: 'string', value: JSON.stringify({ - exists: nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, + exists: + nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, count: queryPlan.length, targetIndex, targetMissing: true, @@ -581,7 +794,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { }); return; } - const rect = target.rect; + const rect = applyFrameOffset(target.rect); const text = target.innerText || target.textContent || ''; reply({ result: { @@ -619,7 +832,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { reply({ result: { type: 'string', value: JSON.stringify({ exists: false }) } }); return; } - const rect = resolvedQueryPlan.rect; + const rect = applyFrameOffset(resolvedQueryPlan.rect); const text = resolvedQueryPlan.innerText || resolvedQueryPlan.textContent || ''; const viewportWidth = 1280; const viewportHeight = 720; @@ -784,7 +997,11 @@ async function runMcpRequests( const child = await browser.start(options); try { - const responsesPromise = collectResponses(child, requests.length + 1); + const responsesPromise = collectResponses( + child, + requests.length + 1, + options.responseTimeoutMs + ); child.stdin.write( encodeMessage({ jsonrpc: '2.0', @@ -801,6 +1018,7 @@ async function runMcpRequests( for (const request of requests) { child.stdin.write(encodeMessage(request)); } + child.stdin.end(); return await responsesPromise; } finally { @@ -820,13 +1038,15 @@ describe('ccs-browser MCP server', () => { [{ 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; + 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', @@ -842,6 +1062,7 @@ describe('ccs-browser MCP server', () => { 'browser_hover', 'browser_query', 'browser_take_element_screenshot', + 'browser_wait_for_event', ]); const clickTool = tools.find((tool) => tool.name === 'browser_click'); @@ -887,8 +1108,9 @@ describe('ccs-browser MCP server', () => { { serverPath: bootstrapServerPath, childEnv: { - NODE_PATH: join(process.cwd(), 'node_modules'), + NODE_PATH: resolveNodeModulesPath(), }, + responseTimeoutMs: 12000, } ); @@ -899,7 +1121,7 @@ describe('ccs-browser MCP server', () => { } finally { rmSync(installDir, { recursive: true, force: true }); } - }); + }, 10000); it('navigates successfully after readiness polling', async () => { const responses = await runMcpRequests( @@ -935,42 +1157,38 @@ describe('ccs-browser MCP server', () => { 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'), - }, + 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' }, - }, + }, + ], + [ + { + 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 - ); + 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( @@ -992,25 +1210,35 @@ describe('ccs-browser MCP server', () => { jsonrpc: '2.0', id: 4, method: 'tools/call', - params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } }, + 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' } }, + 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 === 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'); + 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 () => { @@ -1123,12 +1351,18 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit'); + 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'); + 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); @@ -1167,7 +1401,9 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 20))).toContain('status: clicked'); + 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' @@ -1205,7 +1441,9 @@ describe('ccs-browser MCP server', () => { 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'); + 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); @@ -1234,8 +1472,12 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('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 () => { @@ -1264,8 +1506,12 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('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 () => { @@ -1384,8 +1630,12 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked'); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('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('requires real mouse movement for hover-only targets and reports hover failures', async () => { @@ -1556,12 +1806,18 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain('exists: true'); + 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:'); + 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); @@ -1645,7 +1901,15 @@ describe('ccs-browser MCP server', () => { currentUrl: 'https://example.com/', query: { '.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', + }, ], }, }, @@ -1707,7 +1971,16 @@ describe('ccs-browser MCP server', () => { display: 'block', visibility: 'visible', opacity: '0.95', - rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 }, + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, }, ], }, @@ -1732,7 +2005,9 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 40))).toContain('status: satisfied'); + expect(getResponseText(responses.find((message) => message.id === 40))).toContain( + 'status: satisfied' + ); }); it('waits for page text includes and returns timeout summaries', async () => { @@ -1752,7 +2027,16 @@ describe('ccs-browser MCP server', () => { display: 'block', visibility: 'visible', opacity: '0.2', - rect: { x: 10, y: 10, width: 40, height: 20, top: 10, right: 50, bottom: 30, left: 10 }, + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, }, ], }, @@ -1790,11 +2074,15 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 41))).toContain('status: satisfied'); + 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'); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'opacity=0.2' + ); }); it('waits for selector text includes', async () => { @@ -1807,8 +2095,28 @@ describe('ccs-browser MCP server', () => { 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' }], + [ + { + 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', + }, + ], ], }, }, @@ -1832,7 +2140,9 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 45))).toContain('status: satisfied'); + 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 () => { @@ -1845,10 +2155,46 @@ describe('ccs-browser MCP server', () => { 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 } }, + { + 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, + }, + }, ], ], }, @@ -1887,7 +2233,9 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 43))).toContain('status: satisfied'); + 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' ); @@ -1910,7 +2258,7 @@ describe('ccs-browser MCP server', () => { 'throw new Error("boom")': { error: 'boom', }, - 'window': { + window: { nonSerializable: true, }, }, @@ -1966,7 +2314,9 @@ describe('ccs-browser MCP server', () => { { 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( + 'mode: readonly' + ); expect(getResponseText(responses.find((message) => message.id === 50))).toContain( 'value: {"hovered":true,"label":"Archive"}' ); @@ -1976,7 +2326,9 @@ describe('ccs-browser MCP server', () => { 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 === 53))).toContain( + 'Browser MCP failed: boom' + ); expect(getResponseText(responses.find((message) => message.id === 54))).toContain( 'Browser MCP failed: evaluation result is not JSON-serializable' ); @@ -2010,8 +2362,195 @@ describe('ccs-browser MCP server', () => { { 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"'); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'mode: readwrite' + ); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'value: "ok"' + ); + }); + + it('queries inside an iframe selected by frameSelector', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Frame Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#details-frame', + query: { + '.save-button': { + exists: true, + connected: true, + innerText: 'Save', + textContent: 'Save', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 55, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.save-button', + frameSelector: '#details-frame', + fields: ['exists', 'innerText'], + }, + }, + }, + ] + ); + + expect( + getMockFrame( + { + frames: [{ selector: '#details-frame' }], + id: 'x', + title: 'x', + currentUrl: 'https://example.com/', + }, + '#details-frame' + ) + ).toBeDefined(); + expect(getResponseText(responses.find((message) => message.id === 55))).toContain( + 'innerText: Save' + ); + }); + + it('queries through open shadow roots when pierceShadow is true', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Shadow Page', + currentUrl: 'https://example.com/', + shadowRoots: [ + { + hostSelector: 'app-toolbar', + query: { + 'button[action="archive"]': { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 56, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: 'button[action="archive"]', + pierceShadow: true, + fields: ['exists', 'innerText'], + }, + }, + }, + ] + ); + + expect( + getMockShadowRoot({ + shadowRoots: [{ hostSelector: 'app-toolbar' }], + id: 'x', + title: 'x', + currentUrl: 'https://example.com/', + }) + ).toBeDefined(); + expect(getResponseText(responses.find((message) => message.id === 56))).toContain( + 'innerText: Archive' + ); + }); + + it('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: 57, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'navigation', urlIncludes: '/checkout' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 57))).toContain( + 'status: observed' + ); + }); + + it('ignores child-frame navigations when waiting for a page navigation event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + navigations: [ + { url: 'https://example.com/embedded-checkout', parentId: 'frame-1' }, + { url: 'https://example.com/checkout' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 58, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'navigation', urlIncludes: '/checkout' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 58)); + expect(text).toContain('status: observed'); + expect(text).toContain('"url":"https://example.com/checkout"'); + expect(text).not.toContain('embedded-checkout'); }); it('captures element screenshots using the post-scroll visible clip and reports failures', async () => { @@ -2160,6 +2699,83 @@ describe('ccs-browser MCP server', () => { 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 () => { @@ -2217,7 +2833,9 @@ describe('ccs-browser MCP server', () => { 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'); + 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 () => { @@ -2318,10 +2936,14 @@ describe('ccs-browser MCP server', () => { 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'); + 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'); + expect(getResponseText(nonEditable)).toContain( + 'element is not text-editable for selector: #plain' + ); }); }); From e92a2c11bfedd8c4d18cf591baa15e9236aacb68 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 13:59:15 +0800 Subject: [PATCH 03/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E5=9B=9B=E9=98=B6=E6=AE=B5=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 browser_click 的精细点击参数,并新增 browser_press_key 与 browser_scroll,继续扩展 browser MCP 的真实交互能力。 同步更新单测与浏览器自动化文档,确保 Phase 4 能力完成并通过验证。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 10 +- lib/mcp/ccs-browser-server.cjs | 434 ++++++++++++++++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 449 +++++++++++++++++- 3 files changed, 868 insertions(+), 25 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index f4a72d3d..4928b65a 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,6 +1,6 @@ # Browser Automation -Last Updated: 2026-04-17 +Last Updated: 2026-04-18 CCS provides browser automation through two separate runtime paths: @@ -23,7 +23,7 @@ enabled. A recent Chrome update alone is not sufficient. The managed `ccs-browser` runtime currently exposes five tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` -- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_take_screenshot` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_take_screenshot` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` @@ -41,6 +41,12 @@ Phase 3 capability details: - the same scoped-selector tools accept optional `pierceShadow: true` to search open shadow roots beneath the selected root - `browser_wait_for_event` observes typed page or browser events for dialogs, navigation, requests, and downloads +Phase 4 capability details: + +- `browser_click` also accepts optional `offsetX`, `offsetY`, `button`, and `clickCount` for more precise element-relative click control +- `browser_press_key` sends real browser-level key events, supports modifier combinations plus repeat counts, and covers a focused set of common special keys such as `Enter`, `Tab`, `Escape`, and arrow keys +- `browser_scroll` supports page-level `by-offset` scrolling and element-scoped `by-offset` or `into-view` scrolling, including same-origin iframe scoping + A common hover-debug workflow is: 1. call `browser_hover` to move the browser pointer onto the card or trigger diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 759cf306..b39b33f3 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -43,6 +43,8 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -58,6 +60,8 @@ const TOOL_NAMES = [ TOOL_NAVIGATE, TOOL_CLICK, TOOL_TYPE, + TOOL_PRESS_KEY, + TOOL_SCROLL, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -278,6 +282,24 @@ function getTools() { 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, @@ -312,6 +334,82 @@ 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_TAKE_SCREENSHOT, description: @@ -795,6 +893,49 @@ function requirePositiveIntegerOrDefault(value, label, fallback) { 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 getBrowserEvalMode() { const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim(); if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') { @@ -1058,12 +1199,20 @@ async function handleClick(toolArgs) { 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 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]; @@ -1113,52 +1262,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}\nnth: ${targetIndex}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\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) { @@ -1240,6 +1441,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; @@ -1865,6 +2243,22 @@ 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 9afc8d3e..a2740854 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -48,6 +48,10 @@ type MockClickState = { detachAfterMouseDown?: boolean; mouseSequenceError?: string; label?: string; + expectedOffset?: { x: number; y: number }; + expectedButton?: 'left' | 'middle' | 'right'; + expectedClickCount?: number; + requireDoubleClickEvent?: boolean; }; type MockClickPlan = MockClickState | MockClickState[]; @@ -144,6 +148,20 @@ type MockPageState = { 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'); @@ -521,6 +539,45 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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') { @@ -627,7 +684,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (expression.includes('scrollIntoView') && expression.includes('.click()')) { + if (expression.includes('scrollIntoView') && expression.includes('resolvedOffsetX')) { const selector = parseJsonArgument(expression, 'selector') || ''; const nth = parseNumberArgument(expression, 'nth') ?? 0; const clickPlan = page.click?.[selector]; @@ -636,6 +693,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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)' @@ -662,6 +720,10 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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; @@ -712,13 +774,49 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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: 'ok' } }); + reply({ + result: { + type: 'string', + value: JSON.stringify({ + resolvedOffsetX: offsetX ?? 50, + resolvedOffsetY: offsetY ?? 10, + button, + clickCount, + }), + }, + }); return; } replyError(resolvedClickPlan.mouseSequenceError); @@ -728,7 +826,17 @@ function createMockBrowser(pagesInput: MockPageState[]) { replyError(resolvedClickPlan.error); return; } - reply({ result: { type: 'string', value: 'ok' } }); + reply({ + result: { + type: 'string', + value: JSON.stringify({ + resolvedOffsetX: offsetX ?? 50, + resolvedOffsetY: offsetY ?? 10, + button, + clickCount, + }), + }, + }); return; } @@ -950,6 +1058,80 @@ function createMockBrowser(pagesInput: MockPageState[]) { }); 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; + } }); }); @@ -1056,6 +1238,8 @@ describe('ccs-browser MCP server', () => { 'browser_navigate', 'browser_click', 'browser_type', + 'browser_press_key', + 'browser_scroll', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1068,6 +1252,21 @@ describe('ccs-browser MCP server', () => { 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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ @@ -1638,6 +1837,250 @@ describe('ccs-browser MCP server', () => { ); }); + 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( [ From ff0e7085b815e290d61cff39d8099e61df6a793b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 16:39:51 +0800 Subject: [PATCH 04/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E4=BA=94=E9=98=B6=E6=AE=B5=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 selected page 会话状态与页面选择、打开、关闭能力, 并为 browser_get_session_info 增加 selected 标记与最小多标签文档说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 31 +- lib/mcp/ccs-browser-server.cjs | 228 ++++++++++++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 318 +++++++++++++++++- 3 files changed, 559 insertions(+), 18 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 4928b65a..8c82d592 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -23,7 +23,7 @@ enabled. A recent Chrome update alone is not sufficient. The managed `ccs-browser` runtime currently exposes five tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` -- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_take_screenshot` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` @@ -47,6 +47,35 @@ Phase 4 capability details: - `browser_press_key` sends real browser-level key events, supports modifier combinations plus repeat counts, and covers a focused set of common special keys such as `Enter`, `Tab`, `Escape`, and arrow keys - `browser_scroll` supports page-level `by-offset` scrolling and element-scoped `by-offset` or `into-view` scrolling, including same-origin iframe scoping +Phase 5 capability details: + +- `browser_get_session_info` marks the currently selected page +- `browser_select_page` chooses the default target page for later tool calls +- `browser_open_page` opens a new tab and makes it selected +- `browser_close_page` closes the selected page by default and deterministically falls back only when the selected page is closed or no longer available +- existing tools still honor explicit `pageIndex`; when omitted, they resolve through the selected page +- selected page state is session-local MCP runtime state and is not persisted across runtime restarts + +Minimal multi-tab workflow examples: + +```json +{ + "name": "browser_select_page", + "arguments": { + "pageIndex": 1 + } +} +``` + +```json +{ + "name": "browser_open_page", + "arguments": { + "url": "https://example.com/docs" + } +} +``` + A common hover-debug workflow is: 1. call `browser_hover` to move the browser pointer onto the card or trigger diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index b39b33f3..4a6ed61a 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -45,6 +45,9 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -62,6 +65,9 @@ const TOOL_NAMES = [ TOOL_TYPE, TOOL_PRESS_KEY, TOOL_SCROLL, + TOOL_SELECT_PAGE, + TOOL_OPEN_PAGE, + TOOL_CLOSE_PAGE, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -91,6 +97,8 @@ const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; +let selectedPageId = ''; +let messageQueue = Promise.resolve(); function addSocketListener(socket, eventName, handler) { if (typeof socket.addEventListener === 'function') { @@ -410,6 +418,41 @@ function getTools() { 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_TAKE_SCREENSHOT, description: @@ -675,30 +718,100 @@ 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 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) { + 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'); } @@ -2186,6 +2299,64 @@ async function handleWaitForEvent(toolArgs) { return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`; } +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); + 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, + }); + await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(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 handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -2213,8 +2384,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; } @@ -2259,6 +2433,30 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { @@ -2437,11 +2635,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/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index a2740854..cc95834d 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -379,6 +379,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { let wsServer: WebSocketServer | null = null; let browserSocketPath = ''; const pageStates = new Map(); + let nextPageCounter = pagesInput.length + 1; for (const [index, page] of pagesInput.entries()) { pageStates.set(`/devtools/page/${index + 1}`, { @@ -395,13 +396,15 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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 address = httpServer?.address(); - const serverPort = address && typeof address !== 'string' ? address.port : 0; + const pageEntries = Array.from(pageStates.entries()); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify([ - ...Array.from(pageStates.entries()).map(([wsPath, page]) => ({ + ...pageEntries.map(([wsPath, page]) => ({ id: page.id, type: 'page', title: page.title, @@ -419,6 +422,47 @@ function createMockBrowser(pagesInput: MockPageState[]) { ); 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'); }); @@ -1240,6 +1284,9 @@ describe('ccs-browser MCP server', () => { 'browser_type', 'browser_press_key', 'browser_scroll', + 'browser_select_page', + 'browser_open_page', + 'browser_close_page', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1268,6 +1315,17 @@ describe('ccs-browser MCP server', () => { 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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -1281,6 +1339,260 @@ describe('ccs-browser MCP server', () => { } }); + 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('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'); From e0b58ee1621df9aa7fddc9b0fcdaef12ee865a0c Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 16:51:06 +0800 Subject: [PATCH 05/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E5=85=AD=E9=98=B6=E6=AE=B5=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确 Phase 6A/6B 的拆分,收敛 network interception 与 mock responses 的最小闭环, 并固化规则绑定、请求摘要与页级拦截会话的设计边界。 Co-Authored-By: Claude Opus 4.7 --- .../2026-04-18-browser-mcp-phase6-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/reports/2026-04-18-browser-mcp-phase6-design.md diff --git a/docs/reports/2026-04-18-browser-mcp-phase6-design.md b/docs/reports/2026-04-18-browser-mcp-phase6-design.md new file mode 100644 index 00000000..3763c1b5 --- /dev/null +++ b/docs/reports/2026-04-18-browser-mcp-phase6-design.md @@ -0,0 +1,272 @@ +# Browser MCP Phase 6 Design + +Last Updated: 2026-04-18 + +## Goal + +在 `feat/browser-mcp-hover-phase6` worktree 中,为 CCS-managed `ccs-browser` MCP runtime 设计 Browser MCP Phase 6。 +本阶段延续既有 browser MCP 能力蓝图,优先聚焦 network interception / mock responses,并保持 Phase 1-5 一致的最小增量策略。 + +Phase 6 不在当前阶段一次性做完整网络代理层,而是拆成两个连续子阶段: + +- **Phase 6A**:request observation + scoped interception skeleton +- **Phase 6B**:mock responses + +## Scope Summary + +### In Scope for Phase 6A + +- 新增会话级 interception rule 管理能力 +- 新增近期请求摘要查看能力 +- 基于目标页建立 Fetch interception 会话 +- 支持最小匹配集:`urlIncludes`、`method` +- 支持最小动作集:`continue`、`fail` +- 维持 session-local state,不做跨 runtime 重启持久化 + +### Deferred to Phase 6B + +- `fulfill` / mock response action +- 自定义 status / headers / body +- response body 编码与更复杂序列化 +- 更复杂的匹配条件,如资源类型、headers、正则、优先级 +- 浏览器级或跨页共享拦截规则 + +## Architecture + +Phase 6 继续沿用现有 `lib/mcp/ccs-browser-server.cjs` 单 runtime 架构,不额外引入第二套 browser server,也不改写当前页面选择与工具注册模型。 + +现有 runtime 已具备两类基础能力: + +- 页面级 CDP 请求发送:`sendCdpCommand(page, method, params)` +- 事件型 websocket 监听:当前 `browser_wait_for_event` 已能在页级 websocket 上启用 `Page.enable` / `Network.enable` + +Phase 6 在这一基础上增加一层 **session-local interception state**,而不是引入新的全局配置层。 + +整体结构分为三层: + +1. **Tool surface**:新增规则管理和请求查看工具 +2. **Session-local state**:记录规则、近期请求和页级 interception session +3. **Page interception session**:在具体 page target 上维护 Fetch domain 长连接,处理 paused requests + +## Tool Surface + +Phase 6A 先只新增四个工具,形成最小闭环。 + +### `browser_add_intercept_rule` + +为某个页面添加一条会话级拦截规则。 + +建议参数: + +- `pageIndex?: number` +- `pageId?: string` +- `urlIncludes?: string` +- `method?: string` +- `action: "continue" | "fail"` + +行为: + +- 如果省略 `pageIndex/pageId`,按调用当下的 selected page 解析一次目标页 +- 解析结果固化为具体 `pageId` +- 创建 ruleId 并返回 +- 若目标页尚未建立 interception session,则启动该页的 Fetch interception 长连接 + +### `browser_remove_intercept_rule` + +按 `ruleId` 删除一条规则。 + +建议参数: + +- `ruleId: string` + +行为: + +- 删除对应规则 +- 如果某页已无任何规则,回收该页 interception session + +### `browser_list_intercept_rules` + +列出当前 MCP runtime 内存中的有效规则。 + +返回字段最小化,建议包含: + +- `ruleId` +- `pageId` +- `pageTitle` +- `urlIncludes` +- `method` +- `action` + +### `browser_list_requests` + +返回近期观测到的请求摘要。 + +建议参数: + +- `pageIndex?: number` +- `pageId?: string` +- `limit?: number` + +返回字段最小化,建议包含: + +- `requestId` +- `pageId` +- `url` +- `method` +- `resourceType` +- `matchedRuleId` +- `action` + +## State Model + +Phase 6A 在 runtime 内维护三块 session-local 状态: + +### `interceptRules` + +存储当前有效规则列表。每条规则至少包含: + +- `ruleId` +- `pageId` +- `pageTitleSnapshot` +- `urlIncludes` +- `method` +- `action` +- `createdAt` + +关键约束: + +- 规则绑定到**创建时解析出的具体 pageId** +- 规则不会在后续切换 selected page 时自动漂移 +- 规则不持久化到磁盘 + +### `recentRequests` + +存储近期请求摘要,供 `browser_list_requests` 使用。 + +关键约束: + +- 固定上限,建议 100 条 +- 超过上限时丢弃最旧记录 +- 只保留摘要,不保留 body + +### `interceptSessionsByPageId` + +存储每个被拦截页面对应的长连接会话。 + +每个 session 至少需要管理: + +- `pageId` +- `webSocketDebuggerUrl` +- websocket 实例 +- 当前是否已启用 `Fetch.enable` +- 关联规则数量 +- 最近错误状态(如果需要) + +## Data Flow + +### Add Rule Flow + +1. `browser_add_intercept_rule` 解析目标页 +2. 校验参数与最小匹配条件 +3. 生成 `ruleId` +4. 将规则写入 `interceptRules` +5. 为目标页创建或复用 interception session +6. 在该页 websocket 上启用 `Fetch.enable` +7. 返回 rule 摘要 + +### Request Interception Flow + +1. 页级 interception session 收到 paused request 事件 +2. 从该页规则中按创建顺序依次匹配 +3. 第一条命中规则决定动作 +4. 执行动作: + - `continue` -> 放行请求 + - `fail` -> 以固定错误原因中止请求 +5. 写入 `recentRequests` +6. 若无命中规则,则默认 `continue` + +### Remove Rule Flow + +1. `browser_remove_intercept_rule` 按 `ruleId` 找到规则 +2. 从 `interceptRules` 删除 +3. 若规则所属页已无剩余规则,则关闭该页 interception session +4. 返回删除结果 + +### List Requests Flow + +1. `browser_list_requests` 按可选 page filter 过滤 +2. 按时间倒序返回近期摘要 +3. 应用 `limit` + +## Error Handling and Boundary Rules + +### Parameter Rules + +`browser_add_intercept_rule`: + +- `pageIndex` 和 `pageId` 不能同时传 +- `action` 只能是 `continue` 或 `fail` +- 至少要有一个匹配条件:`urlIncludes` 或 `method` +- 目标页不存在、已关闭或无 websocket target 时直接报错 + +`browser_remove_intercept_rule`: + +- `ruleId` 不存在时报错 + +`browser_list_requests`: + +- `pageIndex` 和 `pageId` 不能同时传 +- `limit` 越界时回退到安全默认值 + +### Lifecycle Rules + +- 所有 interception 规则都是 session-local in-memory state +- 目标页关闭后,该页规则自动失效并从规则列表中清除 +- `browser_list_intercept_rules` 只返回当前仍有效的规则 +- runtime 重启后,所有规则和请求摘要都会丢失 + +### Concurrency Rules + +- 规则管理工具继续沿用当前 runtime 的串行消息语义,避免状态竞态 +- 真正的请求拦截事件在页级长连接中异步处理 +- 页级拦截事件只允许修改自身 pageId 对应的 interception session 状态,以及共享的 `recentRequests` + +## Interaction with Existing Phase 5 State + +Phase 6 必须与现有 selected page 语义兼容: + +- 添加规则时若省略目标页参数,只在创建当下读取 selected page 一次 +- 规则创建完成后绑定到具体 pageId,不再受后续切页影响 +- `browser_select_page`、`browser_open_page`、`browser_close_page` 不直接重写既有规则的 page binding +- 如果 `browser_close_page` 关闭了被规则绑定的页面,相关规则应自动清理 + +## Testing Strategy + +Phase 6A 测试应继续扩展 `tests/unit/hooks/ccs-browser-mcp-server.test.ts` 里的 deterministic harness。 + +最小测试集建议覆盖: + +1. 工具列表与 schema 暴露正确 +2. 添加规则后能在规则列表中看到 +3. 删除规则后规则消失 +4. 两条规则按创建顺序匹配,先命中者生效 +5. 省略 `pageIndex/pageId` 时按当前 selected page 绑定 +6. 切换 selected page 后,旧规则仍绑定原 pageId +7. 关闭绑定页面后规则自动清理 +8. `browser_list_requests` 只返回摘要并 obey `limit` +9. 无命中规则时默认 continue +10. `fail` 动作能生成稳定且可断言的结果 + +## Why This Split + +将 Phase 6 拆成 6A / 6B 的原因是: + +- 先稳定规则管理、页级会话和 paused request 生命周期 +- 再叠加 mock fulfill response,避免一开始把匹配、状态管理、headers/body 序列化全耦合到一起 +- 与 Phase 1-5 的最小闭环推进方式保持一致 + +6A 解决的是“能稳定地拦住并控制请求”;6B 再解决“能把响应内容完全替换掉”。 + +## Out of Scope in This Design Pass + +本轮设计确认只覆盖 Phase 6 的拆分方式与 6A 的最小闭环,不直接进入 6B 的详细实现步骤,也不在这里展开 implementation plan。 From 68b56839db7914887db6a535e5b0ebb9c2beb8a4 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 16:59:25 +0800 Subject: [PATCH 06/47] =?UTF-8?q?test(browser):=20=E6=89=A9=E5=B1=95=20net?= =?UTF-8?q?work=20interception=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../unit/hooks/ccs-browser-mcp-server.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index cc95834d..5d7b4073 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -100,6 +100,20 @@ type MockEvalPlan = Record< } >; +type MockInterceptRuleMatch = { + url: string; + method: string; + resourceType?: string; + requestId?: string; +}; + +type MockInterceptState = { + pausedRequests?: MockInterceptRuleMatch[]; + continuedRequestIds?: string[]; + failedRequests?: Array<{ requestId: string; errorReason?: string }>; + fetchEnabledPatterns?: unknown[]; +}; + type MockPageState = { id: string; title: string; @@ -116,6 +130,7 @@ type MockPageState = { frames?: MockFrameState[]; shadowRoots?: MockShadowRootState[]; events?: MockPageEventPlan; + intercept?: MockInterceptState; screenshot?: { expectedClip?: { x: number; @@ -667,6 +682,51 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } + if (message.method === 'Fetch.enable') { + page.intercept = page.intercept || {}; + page.intercept.fetchEnabledPatterns = Array.isArray(message.params?.patterns) + ? (message.params?.patterns as unknown[]) + : []; + reply({}); + 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, + }, + }, + }) + ); + }, 10 + 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 !== 'Runtime.evaluate') { return; } From 491e9db51ebef504ac8580b0002df31888b9ce8d Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 17:01:47 +0800 Subject: [PATCH 07/47] =?UTF-8?q?test(browser):=20=E6=B7=BB=E5=8A=A0=20Pha?= =?UTF-8?q?se=206A=20=E5=A4=B1=E8=B4=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../unit/hooks/ccs-browser-mcp-server.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 5d7b4073..c814bc03 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1347,6 +1347,10 @@ describe('ccs-browser MCP server', () => { '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_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1386,6 +1390,24 @@ describe('ccs-browser MCP server', () => { 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?.action).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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -1653,6 +1675,228 @@ describe('ccs-browser MCP server', () => { 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('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 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' }, + ], + [ + { + 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_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 944, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 944)); + expect(listText).not.toContain('pageId: page-2'); + }); + 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'); From a93e0b649447610bce3034c92b6e857c7f8d1e8b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 22:03:17 +0800 Subject: [PATCH 08/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E5=85=AD=E9=98=B6=E6=AE=B5=206A=20?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E6=8B=A6=E6=88=AA=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐最小 request interception 工具链与会话内状态管理,确保规则绑定具体页面并通过完整验证门禁。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 23 +- lib/mcp/ccs-browser-server.cjs | 455 ++++++++++++++++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 187 ++++++- 3 files changed, 657 insertions(+), 8 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 8c82d592..16f707f2 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -20,13 +20,14 @@ that already has useful authenticated state. Claude Browser Attach requires a browser launched in attach mode with remote debugging enabled. A recent Chrome update alone is not sufficient. -The managed `ccs-browser` runtime currently exposes five tool groups: +The managed `ccs-browser` runtime currently exposes six tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` - **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` +- **Network interception**: `browser_add_intercept_rule`, `browser_remove_intercept_rule`, `browser_list_intercept_rules`, `browser_list_requests` Notable Phase 1 capability details: @@ -56,6 +57,13 @@ Phase 5 capability details: - existing tools still honor explicit `pageIndex`; when omitted, they resolve through the selected page - selected page state is session-local MCP runtime state and is not persisted across runtime restarts +Phase 6A capability details: + +- interception rules are session-local and bind to the concrete page selected when the rule is created +- Phase 6A supports minimal request matching by `urlIncludes` and `method` +- Phase 6A actions are limited to `continue` and `fail` +- `browser_list_requests` returns recent request summaries, not full bodies + Minimal multi-tab workflow examples: ```json @@ -76,6 +84,17 @@ Minimal multi-tab workflow examples: } ``` +```json +{ + "name": "browser_add_intercept_rule", + "arguments": { + "urlIncludes": "/api", + "method": "GET", + "action": "fail" + } +} +``` + A common hover-debug workflow is: 1. call `browser_hover` to move the browser pointer onto the card or trigger @@ -88,7 +107,7 @@ Scoped selector notes: - `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, request interception, and download acceptance controls are still out of scope +- closed shadow roots, frame-index routing, response mocking, and download acceptance controls are still out of scope Example event wait: diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 4a6ed61a..a7516eed 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -48,6 +48,10 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -68,6 +72,10 @@ const TOOL_NAMES = [ 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_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -99,6 +107,12 @@ let inputBuffer = Buffer.alloc(0); let requestCounter = 0; let selectedPageId = ''; let messageQueue = Promise.resolve(); +let nextInterceptRuleCounter = 1; +const interceptRules = []; +const recentRequests = []; +const interceptSessionsByPageId = new Map(); +const MAX_RECENT_REQUESTS = 100; +const FETCH_FAIL_ERROR_REASON = 'Failed'; function addSocketListener(socket, eventName, handler) { if (typeof socket.addEventListener === 'function') { @@ -453,6 +467,56 @@ function getTools() { 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' }, + action: { type: 'string', enum: ['continue', 'fail'] }, + }, + 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_TAKE_SCREENSHOT, description: @@ -736,6 +800,92 @@ function parseOptionalPageId(toolArgs) { : ''; } +function parseInterceptAction(value) { + if (value !== 'continue' && value !== 'fail') { + throw new Error('action must be one of: continue, fail'); + } + return value; +} + +function parseOptionalMethod(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'method').toUpperCase(); +} + +function parseOptionalUrlIncludes(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlIncludes'); +} + +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 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 || ''}`, + `action: ${rule.action}`, + ].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}`, + ].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); @@ -2299,6 +2449,176 @@ async function handleWaitForEvent(toolArgs) { 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 matchesInterceptRule(rule, paused) { + if (rule.method && rule.method !== String(paused.request?.method || '').toUpperCase()) { + return false; + } + if (rule.urlIncludes && !String(paused.request?.url || '').includes(rule.urlIncludes)) { + 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 = getRulesForPage(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 { + 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, + }); + })(); + 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) { @@ -2341,6 +2661,11 @@ async function handleClosePage(toolArgs) { allowImplicitFallback: false, }); 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; @@ -2357,6 +2682,104 @@ async function handleClosePage(toolArgs) { ].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); + if (!urlIncludes && !method) { + throw new Error('urlIncludes or method is required'); + } + const action = parseInterceptAction(toolArgs.action); + const rule = { + ruleId: createInterceptRuleId(), + pageId: page.id, + pageTitleSnapshot: page.title || '', + urlIncludes, + method, + action, + 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 handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -2457,6 +2880,38 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index c814bc03..1be53ae0 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -112,6 +112,8 @@ type MockInterceptState = { continuedRequestIds?: string[]; failedRequests?: Array<{ requestId: string; errorReason?: string }>; fetchEnabledPatterns?: unknown[]; + enableError?: string; + pauseDispatchDelayMs?: number; }; type MockPageState = { @@ -687,7 +689,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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( @@ -703,7 +710,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { }, }) ); - }, 10 + index * 10); + }, pauseDispatchDelayMs + index * 10); } return; } @@ -757,6 +764,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } + if (expression === '0') { + reply({ result: { type: 'number', value: 0 } }); + return; + } + if (expression.includes('document.title') && expression.includes('location.href')) { reply({ result: { @@ -1856,11 +1868,18 @@ describe('ccs-browser MCP server', () => { expect(listText).toContain('action: continue'); }); - it('removes rules bound to a page after that page is closed', async () => { + 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' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + intercept: { + pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], + }, + }, ], [ { @@ -1882,19 +1901,175 @@ describe('ccs-browser MCP server', () => { jsonrpc: '2.0', id: 943, method: 'tools/call', - params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + 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 listText = getResponseText(responses.find((message) => message.id === 944)); - expect(listText).not.toContain('pageId: page-2'); + 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: 'fulfill', + }, + }, + }, + ] + ); + + 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('returns only the requested number of recent requests', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, + { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 953, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 954, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: { limit: 1 } }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 954)); + expect(listText).toContain('requestId: req-2'); + expect(listText).not.toContain('requestId: req-1'); + }); + + it('fails browser_add_intercept_rule when Fetch.enable fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + enableError: 'Fetch.enable blocked', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 955, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 956, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const addResponse = responses.find((message) => message.id === 955); + expect((addResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(addResponse)).toContain('Browser MCP failed: Fetch.enable blocked'); + + const listText = getResponseText(responses.find((message) => message.id === 956)); + expect(listText).toBe('status: empty'); }); it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { From 23eccb24f3efa285f6eb3894c0d58a3b0f15c987 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 23:09:14 +0800 Subject: [PATCH 09/47] =?UTF-8?q?test(browser):=20=E6=89=A9=E5=B1=95=20ful?= =?UTF-8?q?fill=20interception=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Phase 6B 增加 Fetch.fulfillRequest 记录能力,便于后续 mock response TDD。 Co-Authored-By: Claude Opus 4.7 --- .../unit/hooks/ccs-browser-mcp-server.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 1be53ae0..5e5bd64a 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -107,10 +107,18 @@ type MockInterceptRuleMatch = { requestId?: string; }; +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; @@ -734,6 +742,22 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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 !== 'Runtime.evaluate') { return; } From 1fbb53c4b1355d91b0031f6e5afdec5ec184c14b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 23:12:35 +0800 Subject: [PATCH 10/47] =?UTF-8?q?test(browser):=20=E6=B7=BB=E5=8A=A0=20Pha?= =?UTF-8?q?se=206B=20fulfill=20=E5=A4=B1=E8=B4=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先补 browser_add_intercept_rule 的 fulfill schema、mock response 主路径与基础边界失败测试。 Co-Authored-By: Claude Opus 4.7 --- .../unit/hooks/ccs-browser-mcp-server.test.ts | 189 +++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 5e5bd64a..2f125942 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1432,6 +1432,10 @@ describe('ccs-browser MCP server', () => { expect(addRuleTool?.inputSchema?.properties?.urlIncludes).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.method).toMatchObject({ type: 'string' }); expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.statusCode).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.headers).toMatchObject({ type: 'array' }); + 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' }); @@ -2000,7 +2004,7 @@ describe('ccs-browser MCP server', () => { name: 'browser_add_intercept_rule', arguments: { urlIncludes: '/api', - action: 'fulfill', + action: 'mock', }, }, }, @@ -2012,6 +2016,189 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); }); + 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, + headers: [ + { 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('returns only the requested number of recent requests', async () => { const responses = await runMcpRequests( [ From f133deb5399367cdadd2901fbb29032d39bb5d36 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 23:15:04 +0800 Subject: [PATCH 11/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=20Pha?= =?UTF-8?q?se=206B=20mock=20response=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在现有拦截规则之上补齐 fulfill 动作,支持最小状态码、响应头和文本响应体。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 93 ++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a7516eed..5627d2b8 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -477,7 +477,22 @@ function getTools() { pageId: { type: 'string' }, urlIncludes: { type: 'string' }, method: { type: 'string' }, - action: { type: 'string', enum: ['continue', 'fail'] }, + action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] }, + statusCode: { type: 'integer', minimum: 100, maximum: 599 }, + headers: { + 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, @@ -801,12 +816,56 @@ function parseOptionalPageId(toolArgs) { } function parseInterceptAction(value) { - if (value !== 'continue' && value !== 'fail') { - throw new Error('action must be one of: continue, fail'); + 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 parseOptionalHeaders(value, contentType) { + const headers = []; + if (Array.isArray(value)) { + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + throw new Error('headers entries must be objects with name and value'); + } + const name = requireNonEmptyString(entry.name, 'headers.name'); + const headerValue = String(entry.value ?? ''); + headers.push({ name, value: headerValue }); + } + } else if (value !== undefined) { + throw new Error('headers 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 ''; @@ -862,6 +921,8 @@ function formatInterceptRules(rules) { `urlIncludes: ${rule.urlIncludes || ''}`, `method: ${rule.method || ''}`, `action: ${rule.action}`, + `statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`, + `contentType: ${rule.action === 'fulfill' ? rule.contentType || '' : 'n/a'}`, ].join('\n') ) .join('\n---\n'); @@ -881,6 +942,7 @@ function formatRecentRequests(entries) { `resourceType: ${entry.resourceType || ''}`, `matchedRuleId: ${entry.matchedRuleId || 'none'}`, `action: ${entry.action}`, + `statusCode: ${entry.action === 'fulfill' ? entry.statusCode : 'n/a'}`, ].join('\n') ) .join('\n---\n'); @@ -2574,6 +2636,19 @@ async function ensureInterceptSession(page) { 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.headers, + body: encodeFulfillBody(matchedRule.body), + }, + }) + ); } else { ws.send( JSON.stringify({ @@ -2591,6 +2666,7 @@ async function ensureInterceptSession(page) { resourceType: String(paused.resourceType || ''), matchedRuleId: matchedRule ? matchedRule.ruleId : '', action, + statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, }); })(); activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); @@ -2696,10 +2772,15 @@ async function handleAddInterceptRule(toolArgs) { } const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes); const method = parseOptionalMethod(toolArgs.method); + const action = parseInterceptAction(toolArgs.action); if (!urlIncludes && !method) { throw new Error('urlIncludes or method is required'); } - const action = parseInterceptAction(toolArgs.action); + const statusCode = parseOptionalStatusCode(toolArgs.statusCode); + const contentType = + toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); + const body = parseOptionalBody(toolArgs.body); + const headers = parseOptionalHeaders(toolArgs.headers, contentType); const rule = { ruleId: createInterceptRuleId(), pageId: page.id, @@ -2707,6 +2788,10 @@ async function handleAddInterceptRule(toolArgs) { urlIncludes, method, action, + statusCode: action === 'fulfill' ? statusCode : 0, + contentType: action === 'fulfill' ? contentType : '', + headers: action === 'fulfill' ? headers : [], + body: action === 'fulfill' ? body : '', createdAt: new Date().toISOString(), }; interceptRules.push(rule); From 447b5c8bc1b311530bf533fa836218ae40f991e0 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 23:38:28 +0800 Subject: [PATCH 12/47] =?UTF-8?q?test(browser):=20=E8=A1=A5=E5=85=85=20Pha?= =?UTF-8?q?se=206B=20=E8=BE=B9=E7=95=8C=E8=A6=86=E7=9B=96=E5=B9=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 fulfill 参数校验与页面关闭后的状态清理回归测试,确保 mock response 行为在边界条件下可验证。 同步 Browser Automation 文档,明确 Phase 6B 的 fulfill 能力与 session-local 语义。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 16 +- .../unit/hooks/ccs-browser-mcp-server.test.ts | 149 ++++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 16f707f2..3b1b81e7 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -57,12 +57,15 @@ Phase 5 capability details: - existing tools still honor explicit `pageIndex`; when omitted, they resolve through the selected page - selected page state is session-local MCP runtime state and is not persisted across runtime restarts -Phase 6A capability details: +Phase 6 capability details: - interception rules are session-local and bind to the concrete page selected when the rule is created -- Phase 6A supports minimal request matching by `urlIncludes` and `method` +- Phase 6 supports minimal request matching by `urlIncludes` and `method` - Phase 6A actions are limited to `continue` and `fail` +- Phase 6B adds `fulfill` mock responses on top of the existing session-local interception model +- fulfill rules can return a custom status code, optional headers, and a UTF-8 response body - `browser_list_requests` returns recent request summaries, not full bodies +- response bodies are applied only to the matched request and are not persisted beyond the current MCP session Minimal multi-tab workflow examples: @@ -88,9 +91,12 @@ Minimal multi-tab workflow examples: { "name": "browser_add_intercept_rule", "arguments": { - "urlIncludes": "/api", + "urlIncludes": "/api/mock/users", "method": "GET", - "action": "fail" + "action": "fulfill", + "statusCode": 200, + "contentType": "application/json", + "body": "{\"users\":[1]}" } } ``` @@ -107,7 +113,7 @@ Scoped selector notes: - `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, response mocking, and download acceptance controls are still out of scope +- closed shadow roots, frame-index routing, richer request matching, and download acceptance controls are still out of scope Example event wait: diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 2f125942..bffe461d 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -2199,6 +2199,155 @@ describe('ccs-browser MCP server', () => { 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 headers 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', + headers: '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: headers must be an array'); + }); + + it('rejects fulfill rules when a header 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', + headers: [{ 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: headers.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('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( [ From a5ef77a3d8928440d927c84d207477993cae3d6a Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 23:57:21 +0800 Subject: [PATCH 13/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E4=B8=83=E9=98=B6=E6=AE=B5=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确 Phase 7 继续沿 network interception 路线推进 richer request matching。 收敛匹配增强的范围、边界、数据模型与测试策略,为后续在独立 phase7 worktree 中实现做准备。 Co-Authored-By: Claude Opus 4.7 --- .../2026-04-18-browser-mcp-phase7-design.md | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/reports/2026-04-18-browser-mcp-phase7-design.md diff --git a/docs/reports/2026-04-18-browser-mcp-phase7-design.md b/docs/reports/2026-04-18-browser-mcp-phase7-design.md new file mode 100644 index 00000000..73ab8d82 --- /dev/null +++ b/docs/reports/2026-04-18-browser-mcp-phase7-design.md @@ -0,0 +1,323 @@ +# Browser MCP Phase 7 Design + +Last Updated: 2026-04-18 + +## Goal + +在新的 `feat/browser-mcp-hover-phase7` worktree 中,为 CCS-managed `ccs-browser` MCP runtime 设计 Browser MCP Phase 7。 +本阶段延续 Phase 6 的 network interception 路线,优先聚焦 **richer request matching**,让现有拦截规则可以按更细的请求特征命中,同时保持既有最小增量策略。 + +## Scope Summary + +### In Scope + +- 在现有 `browser_add_intercept_rule` 基础上扩展更丰富的匹配条件 +- 新增最小匹配维度: + - `resourceType` + - `urlPattern` 或 `urlRegex`(二选一) + - `headerMatchers` + - `priority` +- 将规则匹配顺序从“创建顺序”扩展为: + 1. `priority` 从高到低 + 2. 同优先级按创建顺序 +- 扩展 `browser_list_intercept_rules` 输出,展示新增匹配摘要 +- 保持 `browser_list_requests` 为请求摘要视图,不输出 request/response body +- 保持 selected page 解析语义、规则绑定具体 `pageId`、session-local runtime state、页级 websocket interception session 与 `messageQueue` 串行语义 + +### Explicitly Out of Scope + +- 跨页共享规则或浏览器级全局规则 +- 下载接受/拒绝控制与下载文件管理 +- 请求体匹配 +- 响应阶段匹配 +- 更复杂布尔逻辑(如任意/全部组、嵌套条件、否定条件) +- 完整网络代理层 + +## Why Phase 7 Follows Phase 6 + +Phase 6 已建立: + +- page-bound 的 interception rule 管理面 +- session-local `interceptRules` / `recentRequests` / `interceptSessionsByPageId` +- `continue` / `fail` / `fulfill` 三种最小动作集 + +历史设计里也已明确把以下能力延后到后续阶段: + +- 更复杂的匹配条件,如 `resourceType`、`headerMatchers`、正则、优先级 +- 跨页共享规则 +- 下载接受控制 + +因此,Phase 7 继续沿 network interception 主线推进 richer matching,是与现有实现最连续、收益最大的下一阶段;它能显著提升规则可用性,同时不会把作用域和下载控制这两类新问题混进同一期。 + +## Architecture + +Phase 7 继续沿用 `lib/mcp/ccs-browser-server.cjs` 的单 runtime 架构,不引入新的 browser runtime,也不改写 Phase 5 的 selected page 模型和 Phase 6 的 interception session 生命周期。 + +本阶段仍沿用三层结构: + +1. **Tool surface**:复用现有四个 interception 相关工具,只扩展参数与输出摘要 +2. **Session-local state**:继续复用 `interceptRules`、`recentRequests`、`interceptSessionsByPageId` +3. **Page interception session**:继续由具体 page target 的 websocket 消费 paused requests,并根据 richer matching 结果执行动作 + +关键设计约束: + +- 规则仍在创建时解析并固化为具体 `pageId` +- 规则不会在后续 selected page 切换时自动漂移 +- 新增 richer matching 不改变现有 action 执行路径,只改变“哪条规则命中” +- `messageQueue` 继续只负责工具入口串行语义;页级 websocket 仍异步接收 paused request 事件 + +## Tool Surface + +Phase 7 不新增新工具,继续复用: + +- `browser_add_intercept_rule` +- `browser_remove_intercept_rule` +- `browser_list_intercept_rules` +- `browser_list_requests` + +### `browser_add_intercept_rule` + +在 Phase 6 参数基础上,扩展最小 richer matching 参数: + +- `pageIndex?: number` +- `pageId?: string` +- `urlIncludes?: string` +- `method?: string` +- `resourceType?: string` +- `urlPattern?: string` +- `urlRegex?: string` +- `headerMatchers?: Array<{ name: string; valueIncludes?: string; valueRegex?: string }>` +- `priority?: number` +- `action: "continue" | "fail" | "fulfill"` +- `statusCode?`, `contentType?`, `responseHeaders?`, `body?`(仅 `action: "fulfill"` 时沿用 Phase 6B 现有语义;避免与请求匹配用的 `headerMatchers` 混淆) + +约束: + +- `pageIndex` 与 `pageId` 不能同时传 +- `urlPattern` 与 `urlRegex` 不能同时传 +- `priority` 默认 `0` +- `priority` 必须是整数 +- header matcher 的 `name` 必填 +- 每个 header matcher 至少需要一个值条件:`valueIncludes` 或 `valueRegex` +- Phase 7 仍要求至少有一个请求匹配条件,避免创建“无条件全匹配”规则 + +### `browser_remove_intercept_rule` + +保持不变,仅按 `ruleId` 删除对应规则。 + +### `browser_list_intercept_rules` + +在现有输出基础上,新增简洁摘要字段: + +- `priority` +- `resourceType` +- `urlPattern` 或 `urlRegex` +- `headerMatchers` 条件数量或简写摘要 + +要求: + +- 仍保持文本摘要可读性 +- 不直接把长正则或大量 header 条件原样完整展开到难以阅读 + +### `browser_list_requests` + +保持“近期请求摘要”定位,不展示 request/response body。 + +可选补充字段: + +- `matchedRuleId` +- `action` +- `statusCode`(仅 fulfill) +- 必要时增加一个轻量命中说明,如 `matchedBy`,只表示命中依赖了哪类条件,不重复整条规则 + +## State Model + +Phase 7 仍复用 Phase 6 的三块 session-local 状态。 + +### `interceptRules` + +每条规则在 Phase 6 基础上新增: + +- `resourceType` +- `urlPattern` +- `urlRegex` +- `headerMatchers` +- `priority` + +关键约束: + +- 规则绑定到创建时解析出的具体 `pageId` +- 规则仍不持久化到磁盘 +- `priority` 仅影响匹配顺序,不影响工具展示或生命周期清理逻辑 + +### `recentRequests` + +仍然只存储近期请求摘要,继续不保留 body。 + +如增加 `matchedBy`,也只记录轻量摘要,而不是完整匹配细节。 + +### `interceptSessionsByPageId` + +不新增新的 session 级状态表。 + +Phase 7 只扩匹配逻辑,不改 websocket 生命周期模型: + +- 规则创建时按需建立 session +- 页面关闭或无规则时回收 session +- websocket 断开时继续进行现有清理与错误恢复语义 + +## Matching Model + +### Matching Order + +对某个 paused request,只在其所属 `pageId` 的规则集合内匹配。 + +匹配顺序: + +1. `priority` 从高到低 +2. 同优先级按创建顺序 +3. 第一条完整命中的规则生效 + +这意味着 Phase 7 解决了 Phase 6 “只能按创建顺序命中”的限制,同时避免引入更复杂的规则编排系统。 + +### Rule Predicate Evaluation + +每条规则依次检查: + +1. `method` +2. `urlIncludes` +3. `urlPattern` 或 `urlRegex` +4. `resourceType` +5. `headerMatchers` + +所有已提供条件都必须命中,规则才算匹配成功。 + +### Header Matching + +Phase 7 的 header matching 采用最小规则: + +- header 名称按大小写不敏感匹配 +- 每个 matcher 至少要求一个值条件: + - `valueIncludes` + - `valueRegex` +- 多个 header matcher 之间采用“全部满足”语义 + +Phase 7 不支持: + +- any/all 模式切换 +- header 多值数组高级语义 +- 负向匹配 +- body/JSON 级内容匹配 + +## Data Flow + +### Add Rule Flow + +1. `browser_add_intercept_rule` 解析目标页 +2. 校验 richer matching 参数 +3. 生成 `ruleId` +4. 将规则写入 `interceptRules` +5. 为目标页创建或复用 interception session +6. 返回包含新增匹配摘要的 rule 文本 + +### Request Match Flow + +1. 页级 interception session 收到 paused request 事件 +2. 取出该页全部有效规则 +3. 按 `priority DESC + createdAt ASC` 排序 +4. 逐条检查 method / URL / resourceType / `headerMatchers` 条件 +5. 第一条命中规则决定 action: + - `continue` + - `fail` + - `fulfill` +6. 将结果写入 `recentRequests` +7. 若无任何规则命中,则默认 `continue` + +### Remove Rule Flow + +保持 Phase 6 行为: + +1. `browser_remove_intercept_rule` 按 `ruleId` 找到规则 +2. 从 `interceptRules` 删除 +3. 若该页已无剩余规则,则关闭该页 interception session +4. 返回删除结果 + +## Error Handling and Boundary Rules + +### Parameter Rules + +`browser_add_intercept_rule`: + +- `pageIndex` 和 `pageId` 不能同时传 +- `urlPattern` 和 `urlRegex` 不能同时传 +- `priority` 必须是整数 +- `headerMatchers` 必须是数组 +- 每个 header matcher 必须有 `name` +- 每个 header matcher 必须至少有 `valueIncludes` 或 `valueRegex` +- 目标页不存在、已关闭或无 websocket target 时直接报错 + +### Matching Rules + +- 不支持空 header matcher +- 不支持空正则 +- 不支持通过 richer matching 创建跨页规则 +- 同一请求只应用第一条命中的规则 + +### Lifecycle Rules + +- 页面关闭时,绑定该 `pageId` 的规则与近期请求摘要继续按 Phase 6 规则清理 +- websocket 关闭时不保留悬挂 session +- selected page 变化不影响已创建规则的 `pageId` 绑定 + +## Testing Strategy + +Phase 7 继续沿用既有 TDD 路线,在 `tests/unit/hooks/ccs-browser-mcp-server.test.ts` 上扩展 deterministic harness 与行为断言。 + +建议拆分为: + +1. **Schema / 参数失败测试** + - `urlPattern` 与 `urlRegex` 同传时报错 + - `priority` 非整数时报错 + - `headerMatchers` 不是数组时报错 + - header matcher 缺 `name` 报错 + - header matcher 既无 `valueIncludes` 也无 `valueRegex` 报错 + +2. **主路径测试** + - `resourceType` 命中 + - `urlPattern` 命中 + - `urlRegex` 命中 + - `headerMatchers.valueIncludes` 命中 + - `headerMatchers.valueRegex` 命中 + +3. **优先级与回归测试** + - 更高 `priority` 的规则覆盖较低优先级规则 + - 同优先级仍按创建顺序命中 + - selected page 切换后规则仍绑定原 `pageId` + - 页面关闭后 richer matching 规则与请求摘要一起清理 + +4. **仓库验证顺序** + - focused test + - `bun run build:server` + - `bun run format` + - `bun run validate` + - `bun run validate:ci-parity` + +## Documentation Impact + +如果 Phase 7 落地,`docs/browser-automation.md` 需要同步更新: + +- Phase 7 capability details +- richer matching 示例 +- 当前仍未支持的范围:跨页规则、下载控制、请求体匹配等 + +## Phase 7 Deliverable Boundary + +Phase 7 的交付结果应当是: + +- 用户可以继续用现有四个 interception 工具 +- 用户可以创建更精细的 page-bound 规则 +- 用户可以用 `priority` 控制命中顺序 +- 用户仍在同一 session-local interception 模型里工作 +- 代码不因为这期而引入新的全局作用域、下载控制或代理层复杂度 + +这让 Browser MCP 的 network interception 从“最小可用”进入“更稳定可控”,但仍然保持单期可交付范围。 From ef06564b6063e3837706fd8eae4e6d3f315f7f7d Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 00:32:55 +0800 Subject: [PATCH 14/47] =?UTF-8?q?test(browser):=20=E6=89=A9=E5=B1=95=20ric?= =?UTF-8?q?her=20matching=20interception=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- tests/unit/hooks/ccs-browser-mcp-server.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index bffe461d..dad60cd4 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -105,6 +105,7 @@ type MockInterceptRuleMatch = { method: string; resourceType?: string; requestId?: string; + requestHeaders?: Record; }; type MockFulfilledRequest = { @@ -714,6 +715,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { request: { url: paused.url, method: paused.method, + headers: paused.requestHeaders || {}, }, }, }) From cb415b41275bdade0570ef6c46fcb10004b6485b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 00:38:12 +0800 Subject: [PATCH 15/47] =?UTF-8?q?test(browser):=20=E6=B7=BB=E5=8A=A0=20Pha?= =?UTF-8?q?se=207=20richer=20matching=20=E5=A4=B1=E8=B4=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../unit/hooks/ccs-browser-mcp-server.test.ts | 475 +++++++++++++++++- 1 file changed, 467 insertions(+), 8 deletions(-) diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index dad60cd4..77a6f9d7 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1433,9 +1433,15 @@ describe('ccs-browser MCP server', () => { 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?.headers).toMatchObject({ type: 'array' }); + 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' }); @@ -2018,6 +2024,459 @@ describe('ccs-browser MCP server', () => { 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('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/' }], @@ -2144,7 +2603,7 @@ describe('ccs-browser MCP server', () => { urlIncludes: '/api/mock/headers', action: 'fulfill', statusCode: 201, - headers: [ + responseHeaders: [ { name: 'Cache-Control', value: 'no-store' }, { name: 'X-Mocked-By', value: 'ccs-browser' }, ], @@ -2227,7 +2686,7 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); }); - it('rejects fulfill rules when headers is not an array', async () => { + it('rejects fulfill rules when responseHeaders is not an array', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], [ @@ -2240,7 +2699,7 @@ describe('ccs-browser MCP server', () => { arguments: { urlIncludes: '/api/mock', action: 'fulfill', - headers: 'x', + responseHeaders: 'x', body: 'x', }, }, @@ -2250,10 +2709,10 @@ describe('ccs-browser MCP server', () => { const response = responses.find((message) => message.id === 969); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headers must be an array'); + expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array'); }); - it('rejects fulfill rules when a header entry is missing name', async () => { + 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/' }], [ @@ -2266,7 +2725,7 @@ describe('ccs-browser MCP server', () => { arguments: { urlIncludes: '/api/mock', action: 'fulfill', - headers: [{ value: 'x' }], + responseHeaders: [{ value: 'x' }], body: 'x', }, }, @@ -2276,7 +2735,7 @@ describe('ccs-browser MCP server', () => { const response = responses.find((message) => message.id === 970); expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headers.name is required'); + expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required'); }); it('rejects fulfill rules when body is not a string', async () => { From bd073acf4736c337ee9d1d6ff616c27f124ca292 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 00:43:37 +0800 Subject: [PATCH 16/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=20ric?= =?UTF-8?q?her=20request=20matching=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 206 ++++++++++++++++++++++++++++++--- 1 file changed, 192 insertions(+), 14 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 5627d2b8..d1a7590f 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -477,9 +477,26 @@ function getTools() { 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 }, - headers: { + responseHeaders: { type: 'array', items: { type: 'object', @@ -832,19 +849,19 @@ function parseOptionalStatusCode(value) { return value; } -function parseOptionalHeaders(value, contentType) { +function parseOptionalResponseHeaders(value, contentType) { const headers = []; if (Array.isArray(value)) { for (const entry of value) { if (!entry || typeof entry !== 'object') { - throw new Error('headers entries must be objects with name and value'); + throw new Error('responseHeaders entries must be objects with name and value'); } - const name = requireNonEmptyString(entry.name, 'headers.name'); + const name = requireNonEmptyString(entry.name, 'responseHeaders.name'); const headerValue = String(entry.value ?? ''); headers.push({ name, value: headerValue }); } } else if (value !== undefined) { - throw new Error('headers must be an array'); + 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 }); @@ -880,6 +897,89 @@ function parseOptionalUrlIncludes(value) { 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++}`; } @@ -920,6 +1020,11 @@ function formatInterceptRules(rules) { `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'}`, @@ -2519,11 +2624,69 @@ 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) { - if (rule.method && rule.method !== String(paused.request?.method || '').toUpperCase()) { + 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 && !String(paused.request?.url || '').includes(rule.urlIncludes)) { + 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; @@ -2626,7 +2789,7 @@ async function ensureInterceptSession(page) { return; } const paused = message.params || {}; - const matchedRule = getRulesForPage(page.id).find((rule) => matchesInterceptRule(rule, paused)); + const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused)); const action = matchedRule ? matchedRule.action : 'continue'; if (action === 'fail') { ws.send( @@ -2644,7 +2807,7 @@ async function ensureInterceptSession(page) { params: { requestId: paused.requestId, responseCode: matchedRule.statusCode, - responseHeaders: matchedRule.headers, + responseHeaders: matchedRule.responseHeaders, body: encodeFulfillBody(matchedRule.body), }, }) @@ -2772,25 +2935,40 @@ async function handleAddInterceptRule(toolArgs) { } 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); - if (!urlIncludes && !method) { - throw new Error('urlIncludes or method is required'); - } + 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 headers = parseOptionalHeaders(toolArgs.headers, contentType); + 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 : '', - headers: action === 'fulfill' ? headers : [], + responseHeaders: action === 'fulfill' ? responseHeaders : [], body: action === 'fulfill' ? body : '', createdAt: new Date().toISOString(), }; From 126c30ac1af20b3ac6088b17e773ab5a43711f16 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 01:15:39 +0800 Subject: [PATCH 17/47] =?UTF-8?q?test(browser):=20=E8=A1=A5=E5=85=85=20Pha?= =?UTF-8?q?se=207=20=E5=9B=9E=E5=BD=92=E8=A6=86=E7=9B=96=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 richer request matching 的边界与顺序回归覆盖,锁定同优先级规则继续按创建顺序命中,并同步 Phase 7 文档说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 20 ++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 164 ++++++++++++++++++ 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 3b1b81e7..2d205300 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -63,10 +63,17 @@ Phase 6 capability details: - Phase 6 supports minimal request matching by `urlIncludes` and `method` - Phase 6A actions are limited to `continue` and `fail` - Phase 6B adds `fulfill` mock responses on top of the existing session-local interception model -- fulfill rules can return a custom status code, optional headers, and a UTF-8 response body +- fulfill rules can return a custom status code, optional response headers, and a UTF-8 response body - `browser_list_requests` returns recent request summaries, not full bodies - response bodies are applied only to the matched request and are not persisted beyond the current MCP session +Phase 7 capability details: + +- `browser_add_intercept_rule` also accepts `resourceType`, `urlPattern`, `urlRegex`, `headerMatchers`, and `priority` +- richer matching rules remain page-bound and session-local; creating a rule still binds it to the concrete page selected at creation time +- higher `priority` rules win before lower-priority rules, while equal-priority rules continue to follow creation order +- `headerMatchers` are request-matching conditions; `responseHeaders` on `fulfill` rules remain response headers + Minimal multi-tab workflow examples: ```json @@ -91,12 +98,15 @@ Minimal multi-tab workflow examples: { "name": "browser_add_intercept_rule", "arguments": { - "urlIncludes": "/api/mock/users", - "method": "GET", + "resourceType": "XHR", + "headerMatchers": [ + { "name": "x-env", "valueIncludes": "staging" } + ], + "priority": 10, "action": "fulfill", "statusCode": 200, "contentType": "application/json", - "body": "{\"users\":[1]}" + "body": "{\"ok\":true}" } } ``` @@ -113,7 +123,7 @@ Scoped selector notes: - `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, richer request matching, and download acceptance controls are still out of scope +- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, advanced boolean matcher groups, and download acceptance controls are still out of scope Example event wait: diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 77a6f9d7..03fe0e44 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1810,6 +1810,53 @@ describe('ccs-browser MCP server', () => { 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/' }], @@ -2269,6 +2316,75 @@ describe('ccs-browser MCP server', () => { 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[] = [ { @@ -2763,6 +2879,54 @@ describe('ccs-browser MCP server', () => { 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( [ From 26aa8f4c20f4b60390387b40625ee5e5fa130bf6 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 01:43:54 +0800 Subject: [PATCH 18/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=20bro?= =?UTF-8?q?wser=20MCP=20=E7=AC=AC=E5=85=AB=E9=98=B6=E6=AE=B5=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确下载控制与最小上传的下一阶段范围,按官方 CDP 边界区分 browser-scoped downloads 与 page-scoped uploads。 Co-Authored-By: Claude Opus 4.7 --- .../2026-04-19-browser-mcp-phase8-design.md | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 docs/reports/2026-04-19-browser-mcp-phase8-design.md diff --git a/docs/reports/2026-04-19-browser-mcp-phase8-design.md b/docs/reports/2026-04-19-browser-mcp-phase8-design.md new file mode 100644 index 00000000..b2993bf6 --- /dev/null +++ b/docs/reports/2026-04-19-browser-mcp-phase8-design.md @@ -0,0 +1,455 @@ +# Browser MCP Phase 8 Design + +Last Updated: 2026-04-19 + +## Goal + +在当前已完成并提交的 Browser MCP Phase 7 基线之上,为 CCS-managed `ccs-browser` MCP runtime 设计 Browser MCP Phase 8。 + +本阶段延续历史 roadmap 中 Phase 7 预留的 file-transfer / lifecycle completion 方向,但按当前官方 CDP 能力边界做最小修正: + +- **下载控制**:采用 session-local、browser-scoped 的下载策略与下载结果摘要 +- **最小上传**:采用 page-scoped 的 file input 设置能力 + +Phase 8 的目标是补齐“文件流转”这一类真实浏览器工作流缺口,让 browser MCP 不再只有页面交互与网络拦截,还能覆盖最常见的下载与表单文件上传场景。 + +## Why Phase 8 Follows Phase 7 + +历史 spec 的主路线是: + +- Phase 5:page/tab control +- Phase 6:network interception + mock responses +- Phase 7:file-transfer and lifecycle completion +- Phase 8+:recording / replay / higher-level orchestration + +后来实际交付里,Phase 6 被拆成 6A / 6B,Phase 7 又继续完成了 richer request matching。因此当前 browser MCP 已经把 interception 线打磨到了一个较完整的最小闭环: + +- page-bound interception rules +- session-local state +- `continue` / `fail` / `fulfill` +- richer request matching +- recent request summaries + +下一阶段如果继续深挖 interception,会开始落入 request body matching、更复杂 boolean matcher groups、cross-page shared rules 这类更重的规则编排问题;而历史 roadmap 里原本预留的 file transfer 缺口仍未补上。 + +因此,Phase 8 应该回到原 roadmap,优先补: + +- **download handling** +- **minimal upload handling** + +这样既尊重了最初路线,也能让 browser MCP 覆盖更多真实 end-to-end workflow。 + +## Scope Summary + +### In Scope + +1. **下载控制** + - 为当前 attach session 设置下载策略:接受或拒绝下载 + - 支持 session-local 下载目录配置 + - 记录近期下载摘要 + - 支持取消进行中的下载 + - 继续兼容现有 `browser_wait_for_event(kind=download)` 事件等待能力 + +2. **最小上传** + - 为 `` 设置一个或多个本地文件 + - 支持当前已有 selector 作用域能力:`nth`、`frameSelector`、`pierceShadow` + - 保持 selected page 解析语义 + +3. **状态可见性** + - 通过新的下载列表工具查看近期下载摘要与状态 + - 返回清晰的 upload / download 结果摘要 + +### Explicitly Out of Scope + +- 原生系统文件选择器接管 +- drag-and-drop 上传 +- 下载文件内容读取、解析或回传 +- 上传后自动点击 submit 或自动编排后续页面流程 +- 跨 runtime 持久化下载策略或下载记录 +- 基于 interception 的下载内容改写 +- 录制 / 回放 / 宏编排 +- request body matching +- 更复杂 boolean matcher groups +- 跨页共享 interception rules + +## CDP Constraint and Scope Correction + +Phase 8 的关键设计修正来自当前官方 CDP 能力边界: + +### Uploads + +上传侧可基于 `DOM.setFileInputFiles` 实现,因此天然适合继续沿用当前 browser MCP 的 page-scoped / selector-scoped 工具模型。 + +### Downloads + +下载侧主要依赖 Browser domain: + +- `Browser.setDownloadBehavior` +- `Browser.cancelDownload` +- `Browser.downloadWillBegin` +- `Browser.downloadProgress` + +这些接口更接近 **browser-scoped / browser-context-scoped** 能力,而不是某个 page target 独占的 page-bound 能力。因此 Phase 8 不应伪装成“每页独立下载开关”,而应明确采用: + +- **下载控制:session-local, browser-scoped** +- **上传控制:page-scoped** + +这是一个有意的混合作用域设计,但它与 CDP 现实能力一致,也避免了 misleading 的 page-local 承诺。 + +## Architecture + +Phase 8 继续沿用 `lib/mcp/ccs-browser-server.cjs` 的单 runtime 架构,不引入新的 browser runtime,也不重写 Phase 5 selected page 模型或 Phase 6-7 的 interception 架构。 + +本阶段分成两条并行但边界清晰的能力线: + +1. **Download control line** + - browser-scoped CDP Browser domain 控制 + - session-local 下载策略与下载摘要 +2. **File input upload line** + - page-scoped selector 解析 + - DOM file input 赋值 + +### Design Principles + +- 下载控制尊重 CDP Browser domain 的真实作用域,不伪造成 page-bound +- 上传继续复用已有 selector / frame / shadow 解析模型,不新建第二套选择器系统 +- 继续保持 session-local state,不持久化到磁盘 +- 继续保持最小增量策略,不把 download/upload 扩展成完整文件管理系统 +- 不为了 Phase 8 重构现有 wait / event / interception 基础设施 + +## Tool Surface + +Phase 8 推荐新增三个工具。 + +### 1. `browser_set_download_behavior` + +为当前 attach session 设置下载策略。 + +建议参数: + +- `behavior: "accept" | "deny"` +- `downloadPath?: string` +- `eventsEnabled?: boolean` + +行为: + +- `behavior=accept` 时允许下载 +- `behavior=deny` 时拒绝下载 +- `downloadPath` 仅在 accept 时有效;如省略,则 runtime 选择 session-local 默认下载目录 +- `eventsEnabled` 默认开启,保证下载事件和结果摘要可见 +- 作用域为当前 attach session / browser context,而不是单个页面 + +返回字段建议最小包含: + +- `scope: browser` +- `behavior` +- `downloadPath` +- `eventsEnabled` +- `status` + +### 2. `browser_list_downloads` + +列出当前 MCP runtime 记录的近期下载摘要。 + +建议参数: + +- `limit?: number` +- `pageId?: string` + +行为: + +- 默认返回最近下载摘要 +- 可按触发下载时记录的 `pageId` 过滤 +- 不读取下载文件内容 +- 只返回摘要和状态 + +返回字段建议最小包含: + +- `downloadId` +- `guid` +- `pageId` +- `url` +- `suggestedFilename` +- `status` +- `savedPath` +- `startedAt` +- `finishedAt` + +### 3. `browser_cancel_download` + +取消进行中的下载。 + +建议参数: + +- `downloadId?: string` +- `guid?: string` + +行为: + +- 按 runtime 记录的 `downloadId` 或底层 `guid` 定位目标 +- 调用 Browser domain cancel 能力 +- 更新 `recentDownloads` 中对应项状态 + +约束: + +- `downloadId` 与 `guid` 至少提供一个 +- 两者同时提供时必须指向同一条下载记录,否则报错 + +### 4. `browser_set_file_input` + +为 `` 直接设置本地文件。 + +建议参数: + +- `selector: string` +- `files: string[]` +- `pageIndex?: number` +- `pageId?: string` +- `nth?: number` +- `frameSelector?: string` +- `pierceShadow?: boolean` + +行为: + +- 解析目标页 +- 解析 selector 作用域 +- 定位目标节点 +- 要求目标是 `` +- 将本地文件路径集合设置到该 input +- 返回设置结果摘要 + +返回字段建议最小包含: + +- `pageId` +- `pageIndex` +- `selector` +- `fileCount` +- `status` + +## State Model + +Phase 8 继续使用 session-local in-memory state。 + +### `downloadBehaviorState` + +存储当前 attach session 的下载行为配置。 + +建议字段: + +- `behavior` +- `downloadPath` +- `eventsEnabled` +- `updatedAt` + +关键约束: + +- browser-scoped +- 不绑定 pageId +- runtime 重启后丢失 + +### `recentDownloads` + +记录近期下载摘要。 + +建议字段: + +- `downloadId` +- `guid` +- `pageId` +- `url` +- `suggestedFilename` +- `status: "started" | "inProgress" | "completed" | "canceled" | "denied" | "failed"` +- `savedPath` +- `receivedBytes` +- `totalBytes` +- `startedAt` +- `finishedAt` + +关键约束: + +- 固定上限,建议 100 条 +- 只保留摘要,不读文件内容 +- 通过 `guid` 关联 Browser domain 下载事件 + +### Existing selected page state + +上传工具继续复用现有 selected page 模型: + +- 显式 `pageIndex` 优先 +- 否则走 selected page +- stale selected page 时回退到第一可用页面 + +## Data Flow + +### Download behavior update flow + +1. `browser_set_download_behavior` 校验参数 +2. 若 `behavior=accept` 且未提供 `downloadPath`,计算 session-local 默认下载目录 +3. 调用 `Browser.setDownloadBehavior` +4. 更新 `downloadBehaviorState` +5. 返回当前配置摘要 + +### Download observation flow + +1. runtime 接收 `Browser.downloadWillBegin` +2. 生成 `downloadId` +3. 写入 `recentDownloads`,状态为 `started` +4. 记录触发时关联的 `pageId`(若能确定) +5. 若后续收到 `Browser.downloadProgress`: + - 更新 `receivedBytes` / `totalBytes` + - 当 state=`completed` 时写入 `finishedAt` 和可用 `savedPath` + - 当 state=`canceled` 时更新状态 + +### Download cancel flow + +1. `browser_cancel_download` 解析目标记录 +2. 调用 `Browser.cancelDownload` +3. 更新 `recentDownloads` 为 `canceled` +4. 返回取消结果摘要 + +### File input upload flow + +1. `browser_set_file_input` 解析目标页 +2. 解析 `selector` / `nth` / `frameSelector` / `pierceShadow` +3. 定位 DOM 节点 +4. 校验目标是 `` +5. 校验所有本地文件路径存在且为普通文件 +6. 调用 `DOM.setFileInputFiles` +7. 返回设置结果摘要 + +## Error Handling and Boundary Rules + +### `browser_set_download_behavior` + +- `behavior` 只能是 `accept` 或 `deny` +- `behavior=deny` 时不接受 `downloadPath` +- `downloadPath` 不可写时报错 +- Browser domain 下载能力不可用时报错 + +### `browser_list_downloads` + +- `limit` 越界时回退到安全默认值 +- `pageId` 不存在时返回空列表,不报错 + +### `browser_cancel_download` + +- `downloadId` 与 `guid` 至少传一个 +- 指定下载不存在时报错 +- 下载已完成时取消应报明确错误或返回 no-op 状态,但语义必须固定 + +### `browser_set_file_input` + +- `pageIndex` 与 `pageId` 不能同时传 +- `selector` 不能为空 +- `files` 必须是非空数组 +- 文件不存在时报错 +- 文件路径不是普通文件时报错 +- selector 无匹配时报错 +- 多匹配但未提供 `nth` 时应保持与当前 selector 工具一致的目标解析语义 +- 命中的元素不是 `` 时报错 +- 跨域 iframe 不支持 +- closed shadow root 不支持 + +## Interaction with Existing Capabilities + +### With `browser_wait_for_event` + +Phase 8 不替换 `browser_wait_for_event(kind=download)`;它继续负责: + +- 等待下载开始事件 +- 作为轻量事件观察能力存在 + +Phase 8 新增的下载工具负责: + +- 设置下载策略 +- 查看下载摘要 +- 取消下载 + +### With selected page semantics + +- 下载行为是 browser-scoped,不依赖 selected page +- 上传行为继续遵循 selected page 解析语义 +- 这两者的作用域不同是有意设计,不属于不一致 bug + +### With interception state + +- 下载工具不复用 interception rule 管理面 +- 下载记录与 request summaries 是并行状态,不相互嵌套 +- 不把 download handling 强行建模成 interception action + +## Testing Strategy + +Phase 8 继续扩展: + +- `tests/unit/hooks/ccs-browser-mcp-server.test.ts` + +并在 deterministic harness 上增加最小 download / upload 模拟能力。 + +### Required download coverage + +1. 工具列表与 schema 暴露正确 +2. `browser_set_download_behavior` accept 成功 +3. `browser_set_download_behavior` deny 成功 +4. `behavior=deny` + `downloadPath` 报错 +5. 下载开始后 `browser_list_downloads` 能看到 started 摘要 +6. 下载完成后能看到 completed 状态与文件路径 +7. 下载取消后能看到 canceled 状态 +8. `browser_wait_for_event(kind=download)` 继续兼容 +9. `limit` 和 `pageId` 过滤生效 + +### Required upload coverage + +1. 单文件上传成功 +2. 多文件上传成功 +3. selector 无匹配失败 +4. 命中的不是 file input 失败 +5. 文件不存在失败 +6. `frameSelector` 作用域成功 +7. `pierceShadow` 作用域成功 +8. 省略 `pageIndex/pageId` 时命中当前 selected page +9. 显式 `pageIndex` 仍覆盖 selected page + +### Repository validation order + +继续遵循该区域已稳定使用的验证顺序: + +1. focused browser MCP unit tests +2. `bun run build:server` +3. `bun run format` +4. `bun run validate` +5. `bun run validate:ci-parity` + +## Documentation Impact + +如果 Phase 8 实现落地,至少需要同步: + +- `docs/browser-automation.md` + +需要新增: + +- Phase 8 capability details +- 下载控制与上传示例 +- browser-scoped downloads / page-scoped uploads 的作用域说明 +- 新的 out-of-scope 边界说明 + +## Worktree Requirement + +后续实现不应继续直接在当前 Phase 7 worktree 上混写。 + +推荐隔离方式: + +1. 保留 `feat/browser-mcp-hover-phase7` 作为已完成 checkpoint +2. 基于其已验证提交创建新的 Phase 8 worktree +3. 在新 worktree 中完成 Phase 8 plan 与实现 + +## Success Criteria + +Phase 8 成功的标志是: + +1. 用户可以显式控制当前 attach session 的下载接受/拒绝行为 +2. 用户可以查看近期下载摘要并取消进行中的下载 +3. 用户可以向 `` 设置本地文件 +4. 下载与上传能力都遵循清晰、真实的作用域语义 +5. 本期不引入 drag-and-drop、原生文件选择器接管、内容读取或 orchestration 复杂度 + +这使 Browser MCP 在当前阶段从“页面交互 + 网络拦截”扩展到“最小文件流转闭环”,为后续更高阶的录制、回放和 workflow orchestration 打下更完整的基础。 From 492ee5b083520ac3d8e86d1cf26a677a940e5850 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 13:46:07 +0800 Subject: [PATCH 19/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20Pha?= =?UTF-8?q?se=208=20=E6=96=87=E4=BB=B6=E4=BC=A0=E8=BE=93=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 browser-scoped 下载控制与 page-scoped file input 上传,补齐 Phase 8 文档与回归覆盖,并去掉下载列表的 pageId 假能力以保持语义一致。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 16 +- lib/mcp/ccs-browser-server.cjs | 673 ++++++++++++++++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 613 +++++++++++++++- 3 files changed, 1280 insertions(+), 22 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 2d205300..6ef4ea2f 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -1,6 +1,6 @@ # Browser Automation -Last Updated: 2026-04-18 +Last Updated: 2026-04-19 CCS provides browser automation through two separate runtime paths: @@ -20,7 +20,7 @@ that already has useful authenticated state. Claude Browser Attach requires a browser launched in attach mode with remote debugging enabled. A recent Chrome update alone is not sufficient. -The managed `ccs-browser` runtime currently exposes six tool groups: +The managed `ccs-browser` runtime currently exposes seven tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` - **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot` @@ -28,6 +28,7 @@ The managed `ccs-browser` runtime currently exposes six tool groups: - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` - **Network interception**: `browser_add_intercept_rule`, `browser_remove_intercept_rule`, `browser_list_intercept_rules`, `browser_list_requests` +- **File transfer**: `browser_set_download_behavior`, `browser_list_downloads`, `browser_cancel_download`, `browser_set_file_input` Notable Phase 1 capability details: @@ -74,6 +75,15 @@ Phase 7 capability details: - higher `priority` rules win before lower-priority rules, while equal-priority rules continue to follow creation order - `headerMatchers` are request-matching conditions; `responseHeaders` on `fulfill` rules remain response headers +Phase 8 capability details: + +- `browser_set_download_behavior` configures browser-scoped download acceptance for the current attach session; `behavior: "accept"` can use an explicit `downloadPath` or a session-local default directory +- `browser_list_downloads` returns recent download summaries only; it does not read or return downloaded file contents +- `browser_cancel_download` cancels an in-progress download by `downloadId` or `guid` +- `browser_set_file_input` sets one or more local files on a matched `` using the existing selected-page, `pageIndex`, `pageId`, `frameSelector`, `nth`, and `pierceShadow` routing semantics +- download controls are browser-scoped because they map to Chrome's Browser domain; file input uploads remain page-scoped selector actions +- download behavior and recent download summaries are session-local runtime state and are not persisted across runtime restarts + Minimal multi-tab workflow examples: ```json @@ -123,7 +133,7 @@ Scoped selector notes: - `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, advanced boolean matcher groups, and download acceptance controls are still out of scope +- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, and advanced boolean matcher groups are still out of scope Example event wait: diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index d1a7590f..fee8a06e 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -32,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'; @@ -52,6 +55,10 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -76,6 +83,10 @@ const TOOL_NAMES = [ 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_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -108,10 +119,15 @@ let requestCounter = 0; let selectedPageId = ''; let messageQueue = Promise.resolve(); let nextInterceptRuleCounter = 1; +let nextDownloadCounter = 1; 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) { @@ -549,6 +565,65 @@ function getTools() { 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_TAKE_SCREENSHOT, description: @@ -1008,6 +1083,45 @@ function pushRecentRequest(entry) { } } +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'; @@ -1053,6 +1167,27 @@ function formatRecentRequests(entries) { .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); @@ -1223,6 +1358,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), @@ -1259,6 +1565,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`); @@ -1366,6 +1806,23 @@ function requireOptionalStringArray(value, label, allowedValues) { 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') { @@ -1484,6 +1941,94 @@ async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceSh 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 parseEventCondition(value) { if (!value || typeof value !== 'object') { throw new Error('event is required'); @@ -3043,6 +3588,102 @@ async function handleListRequests(toolArgs) { 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); + 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 handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -3175,6 +3816,38 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 03fe0e44..71a4a490 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -73,22 +73,55 @@ type MockWaitPlan = { pageTextSequence?: string[]; }; +type MockDownloadProgressState = { + receivedBytes: number; + totalBytes: number; + state: 'inProgress' | 'completed' | 'canceled'; + filePath?: string; +}; + +type MockDownloadState = { + guid?: string; + url: string; + suggestedFilename: string; + progress?: MockDownloadProgressState[]; +}; + +type MockBrowserState = { + setDownloadBehaviorCalls?: Array<{ + behavior: string; + downloadPath?: string; + eventsEnabled?: boolean; + }>; + canceledDownloadGuids?: string[]; +}; + type MockPageEventPlan = { dialogs?: Array<{ type: string; message: string }>; navigations?: Array<{ url: string; parentId?: string }>; requests?: Array<{ url: string; method: string }>; - downloads?: Array<{ url: string; suggestedFilename: string }>; + downloads?: MockDownloadState[]; }; +type MockFileInputState = { + kind: 'file' | 'nonfile'; + multiple?: boolean; + assignedFiles?: string[]; +}; + +type MockFileInputPlan = MockFileInputState | MockFileInputState[]; + type MockFrameState = { selector: string; query?: Record; visibleText?: string; + fileInputs?: Record; }; type MockShadowRootState = { hostSelector: string; query?: Record; + fileInputs?: Record; }; type MockEvalPlan = Record< @@ -129,6 +162,8 @@ type MockPageState = { id: string; title: string; currentUrl: string; + fileInputs?: Record; + browser?: MockBrowserState; readyStateSequence?: string[]; visibleText?: string; domSnapshot?: string; @@ -508,22 +543,73 @@ function createMockBrowser(pagesInput: MockPageState[]) { wsServer = new WebSocketServer({ server: httpServer as http.Server }); wsServer.on('connection', (socket, request) => { if ((request.url || '') === browserSocketPath) { - const downloadPage = pagesInput.find( - (candidate) => (candidate.events?.downloads?.length || 0) > 0 - ); - if (downloadPage?.events?.downloads?.[0]) { - const download = downloadPage.events.downloads[0]; - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Browser.downloadWillBegin', - params: { - url: download.url, - suggestedFilename: download.suggestedFilename, - }, - }) - ); - }, 10); + const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + if (message.method === 'Browser.setDownloadBehavior') { + browserState.setDownloadBehaviorCalls = browserState.setDownloadBehaviorCalls || []; + browserState.setDownloadBehaviorCalls.push({ + behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', + downloadPath: + typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, + eventsEnabled: + typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, + }); + reply({}); + return; + } + + if (message.method === 'Browser.cancelDownload') { + browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || []; + browserState.canceledDownloadGuids.push(String(message.params?.guid || '')); + reply({}); + } + }); + + for (const page of pagesInput) { + for (const [index, download] of (page.events?.downloads || []).entries()) { + const guid = download.guid || `${page.id}-download-${index + 1}`; + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadWillBegin', + params: { + frameId: `frame-${page.id}`, + guid, + url: download.url, + suggestedFilename: download.suggestedFilename, + }, + }) + ); + }, 10 + index * 40); + + for (const [progressIndex, progress] of (download.progress || []).entries()) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadProgress', + params: { + guid, + totalBytes: progress.totalBytes, + receivedBytes: progress.receivedBytes, + state: progress.state, + filePath: progress.filePath, + }, + }) + ); + }, 20 + index * 40 + progressIndex * 20); + } + } } return; } @@ -534,6 +620,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } + const remoteObjects = new Map(); + socket.on('message', (raw) => { const message = JSON.parse(raw.toString()) as { id: number; @@ -760,6 +848,20 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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; } @@ -795,6 +897,46 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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: { @@ -1389,6 +1531,10 @@ describe('ccs-browser MCP server', () => { '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_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1456,6 +1602,28 @@ describe('ccs-browser MCP server', () => { 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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -4702,6 +4870,413 @@ describe('ccs-browser MCP server', () => { ); }); + it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Reports', + currentUrl: 'https://example.com/reports', + browser: {}, + events: { + downloads: [ + { + guid: 'download-guid-1', + url: 'https://example.com/files/report.csv', + suggestedFilename: 'report.csv', + progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }], + }, + ], + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 57, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', eventsEnabled: true }, + }, + }, + { + jsonrpc: '2.0', + id: 58, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 59, + method: 'tools/call', + params: { + name: 'browser_cancel_download', + arguments: { guid: 'download-guid-1' }, + }, + }, + { + jsonrpc: '2.0', + id: 60, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); + expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); + expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); + expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); + expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); + }); + + it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }], + [ + { + jsonrpc: '2.0', + id: 61, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { + behavior: 'deny', + downloadPath: '/tmp/blocked-downloads', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 61); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: downloadPath is only allowed when behavior=accept' + ); + }); + + it('rejects browser_cancel_download for completed downloads', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Reports', + currentUrl: 'https://example.com/reports', + browser: {}, + events: { + downloads: [ + { + guid: 'download-guid-complete', + url: 'https://example.com/files/report.csv', + suggestedFilename: 'report.csv', + progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }], + }, + ], + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 610, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', eventsEnabled: true }, + }, + }, + { + jsonrpc: '2.0', + id: 611, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 612, + method: 'tools/call', + params: { + name: 'browser_cancel_download', + arguments: { guid: 'download-guid-complete' }, + }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); + const response = responses.find((message) => message.id === 612); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: download is not cancelable in status: completed' + ); + expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined(); + }); + + it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + downloads: [ + { + guid: 'download-guid-2', + url: 'https://example.com/files/export.zip', + suggestedFilename: 'export.zip', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 62, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' }, + }, + }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); + }); + + it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const invoicePath = join(tempDir, 'invoice.pdf'); + const receiptPath = join(tempDir, 'receipt.png'); + writeFileSync(invoicePath, 'invoice'); + writeFileSync(receiptPath, 'receipt'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Root Uploads', + currentUrl: 'https://example.com/root', + fileInputs: { + '#root-upload': { kind: 'file', multiple: true }, + }, + frames: [ + { + selector: '#upload-frame', + fileInputs: { + '#frame-upload': { kind: 'file', multiple: true }, + }, + }, + ], + shadowRoots: [ + { + hostSelector: 'upload-panel', + fileInputs: { + '#shadow-upload': { kind: 'file' }, + }, + }, + ], + }, + { + id: 'page-2', + title: 'Selected Uploads', + currentUrl: 'https://example.com/selected', + fileInputs: { + '#selected-upload': { kind: 'file', multiple: true }, + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 63, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 64, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 65, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { + pageIndex: 0, + selector: '#frame-upload', + files: [invoicePath], + frameSelector: '#upload-frame', + }, + }, + }, + { + jsonrpc: '2.0', + id: 66, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { + pageIndex: 0, + selector: '#shadow-upload', + files: [receiptPath], + pierceShadow: true, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); + expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); + + expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ + invoicePath, + receiptPath, + ]); + expect( + (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles + ).toEqual([invoicePath]); + expect( + (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles + ).toEqual([receiptPath]); + }); + + it('uses pageId for browser_set_file_input when provided', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const assetPath = join(tempDir, 'asset.txt'); + writeFileSync(assetPath, 'asset'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Selected Uploads', + currentUrl: 'https://example.com/selected', + fileInputs: { + '#selected-upload': { kind: 'file' }, + }, + }, + { + id: 'page-2', + title: 'Explicit Uploads', + currentUrl: 'https://example.com/explicit', + fileInputs: { + '#pageid-upload': { kind: 'file' }, + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 67, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } }, + }, + { + jsonrpc: '2.0', + id: 68, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); + expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ + assetPath, + ]); + expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); + }); + + it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const okPath = join(tempDir, 'ok.txt'); + const missingPath = join(tempDir, 'missing.txt'); + writeFileSync(okPath, 'ok'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Upload Errors', + currentUrl: 'https://example.com/', + fileInputs: { + '#real-file-input': { kind: 'file' }, + '#not-file-input': { kind: 'nonfile' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 69, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#not-file-input', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 70, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#real-file-input', files: [missingPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 71, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 69))).toContain( + 'element is not a file input for selector: #not-file-input' + ); + expect(getResponseText(responses.find((message) => message.id === 70))).toContain( + `file does not exist: ${missingPath}` + ); + expect(getResponseText(responses.find((message) => message.id === 71))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + it('waits for a matching navigation event with browser_wait_for_event', async () => { const responses = await runMcpRequests( [ @@ -4717,7 +5292,7 @@ describe('ccs-browser MCP server', () => { [ { jsonrpc: '2.0', - id: 57, + id: 69, method: 'tools/call', params: { name: 'browser_wait_for_event', @@ -4730,7 +5305,7 @@ describe('ccs-browser MCP server', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 57))).toContain( + expect(getResponseText(responses.find((message) => message.id === 69))).toContain( 'status: observed' ); }); From 844b5f6c428e2d2db3e301c805790689c4020661 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sun, 19 Apr 2026 18:41:18 +0800 Subject: [PATCH 20/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20Pha?= =?UTF-8?q?se=209=20=E6=8B=96=E6=8B=BD=E4=B8=8E=E6=8C=87=E9=92=88=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐文件拖拽、元素拖拽与受限 pointer 原语,让 Browser MCP 能覆盖常见上传与页面内拖放交互。 并补足 pageId 路由与回归测试,保持与现有 selected-page 语义一致。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 502 +++++++++++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 733 +++++++++++++++++- 2 files changed, 1220 insertions(+), 15 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index fee8a06e..7e80b71b 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -59,6 +59,9 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -87,6 +90,9 @@ const TOOL_NAMES = [ TOOL_LIST_DOWNLOADS, TOOL_CANCEL_DOWNLOAD, TOOL_SET_FILE_INPUT, + TOOL_DRAG_FILES, + TOOL_DRAG_ELEMENT, + TOOL_POINTER_ACTION, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -113,6 +119,8 @@ 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; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; @@ -624,6 +632,83 @@ function getTools() { 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_TAKE_SCREENSHOT, description: @@ -2029,6 +2114,122 @@ function formatFileInputResult({ pageIndex, selector, nth, frameSelector, pierce 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); + } + + element.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer })); + element.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + const accepted = element.dispatchEvent( + new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }) + ); + + return { accepted }; + })()`; +} + +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 parseEventCondition(value) { if (!value || typeof value !== 'object') { throw new Error('event is required'); @@ -2613,9 +2814,10 @@ async function getElementDiagnostics(page, selector, nth, frameSelector = '', pi return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); } -async function getScrolledElementState(page, selector, frameSelector = '', pierceShadow = false) { +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'}; @@ -2659,7 +2861,8 @@ async function getScrolledElementState(page, selector, frameSelector = '', pierc for (const currentRoot of roots) { matches.push(...Array.from(currentRoot.querySelectorAll(selector))); } - const element = matches[0]; + const targetIndex = nth ?? 0; + const element = matches[targetIndex]; if (!element) { return JSON.stringify({ exists: false }); } @@ -2713,6 +2916,10 @@ async function getScrolledElementState(page, selector, frameSelector = '', pierc 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); @@ -2920,6 +3127,220 @@ async function handleEval(toolArgs) { return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(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); + 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; + 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); + 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'); @@ -2936,14 +3357,7 @@ async function handleHover(toolArgs) { throw new Error(`element is hidden or not interactable for selector: ${selector}`); } - await sendCdpCommand(page, 'Input.dispatchMouseEvent', { - type: 'mouseMoved', - x: state.centerPoint.x, - y: state.centerPoint.y, - button: 'none', - buttons: 0, - pointerType: 'mouse', - }); + await dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false); return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`; } @@ -3684,6 +4098,50 @@ async function handleSetFileInput(toolArgs) { }); } +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); + 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, + }); +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -3848,6 +4306,30 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 71a4a490..fbae2d58 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -111,17 +111,40 @@ type MockFileInputState = { type MockFileInputPlan = MockFileInputState | MockFileInputState[]; +type MockDropzoneState = { + accepted?: boolean; + requireFiles?: boolean; + receivedEventTypes?: string[]; + receivedFiles?: Array<{ name: string; size: number; type: string }>; + error?: string; +}; + +type MockDropzonePlan = MockDropzoneState | MockDropzoneState[]; + +type MockPointerActionRecord = { + type: string; + x?: number; + y?: number; + button?: string; +}; + +type MockDragPlan = { + recordedActions?: MockPointerActionRecord[]; +}; + type MockFrameState = { selector: string; query?: Record; visibleText?: string; fileInputs?: Record; + dropzones?: Record; }; type MockShadowRootState = { hostSelector: string; query?: Record; fileInputs?: Record; + dropzones?: Record; }; type MockEvalPlan = Record< @@ -175,6 +198,8 @@ type MockPageState = { eval?: MockEvalPlan; frames?: MockFrameState[]; shadowRoots?: MockShadowRootState[]; + dropzones?: Record; + drag?: MockDragPlan; events?: MockPageEventPlan; intercept?: MockInterceptState; screenshot?: { @@ -378,6 +403,36 @@ function parseNumberArgument(expression: string, key: string): number | undefine 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 pickMockMatch( plan: T | T[] | undefined, nth = 0 @@ -410,6 +465,41 @@ 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 shiftPageText(page: MockPageState): string { const queue = page.wait?.pageTextSequence; if (!queue || queue.length === 0) { @@ -443,11 +533,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { let nextPageCounter = pagesInput.length + 1; for (const [index, page] of pagesInput.entries()) { - pageStates.set(`/devtools/page/${index + 1}`, { - visibleText: 'Hello from visible text', - domSnapshot: 'Hello from DOM snapshot', - ...page, - }); + 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 = {}) { @@ -688,11 +780,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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; } @@ -868,6 +962,51 @@ function createMockBrowser(pagesInput: MockPageState[]) { const expression = String(message.params?.expression || ''); + if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + const filePayloads = parseParsedJsonArrayArgument<{ + name: string; + size: number; + mimeType: string; + }>(expression, 'filePayloads'); + const eventTypes = Array.from( + expression.matchAll(/new DragEvent\('(dragenter|dragover|drop)'/g) + ).map((match) => match[1]); + + const target = getMockDropzoneState(page, selector, { + nth, + frameSelector, + pierceShadow, + }); + if (!target) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (target.error) { + replyError(target.error); + return; + } + if (eventTypes.length === 0) { + replyError(`drag event sequence not found for selector: ${selector}`); + return; + } + if (target.requireFiles && filePayloads.length === 0) { + reply({ result: { type: 'object', value: { accepted: false } } }); + return; + } + target.receivedEventTypes = eventTypes; + target.receivedFiles = filePayloads.map((file) => ({ + name: file.name, + size: file.size, + type: file.mimeType, + })); + reply({ result: { type: 'object', value: { accepted: target.accepted !== false } } }); + return; + } + if (page.eval?.[expression]) { const evalPlan = page.eval[expression]; if (evalPlan.error) { @@ -1535,6 +1674,9 @@ describe('ccs-browser MCP server', () => { 'browser_list_downloads', 'browser_cancel_download', 'browser_set_file_input', + 'browser_drag_files', + 'browser_drag_element', + 'browser_pointer_action', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1624,6 +1766,25 @@ describe('ccs-browser MCP server', () => { 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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -5277,6 +5438,568 @@ describe('ccs-browser MCP server', () => { ); }); + it('drags local files onto normal, frame, and shadow dropzones', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); + const invoicePath = join(tempDir, 'invoice.pdf'); + const receiptPath = join(tempDir, 'receipt.png'); + writeFileSync(invoicePath, 'invoice'); + writeFileSync(receiptPath, 'receipt'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Uploads', + currentUrl: 'https://example.com/uploads', + dropzones: { + '#dropzone': { accepted: true }, + }, + frames: [ + { + selector: '#upload-frame', + dropzones: { + '#frame-dropzone': { accepted: true }, + }, + }, + ], + shadowRoots: [ + { + hostSelector: 'upload-panel', + dropzones: { + '#shadow-dropzone': { accepted: true }, + }, + }, + ], + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 901, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 902, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#frame-dropzone', + files: [invoicePath], + frameSelector: '#upload-frame', + }, + }, + }, + { + jsonrpc: '2.0', + id: 903, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#shadow-dropzone', + files: [receiptPath], + pierceShadow: true, + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); + expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ + 'dragenter', + 'dragover', + 'drop', + ]); + expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + { name: 'receipt.png', size: 7, type: 'image/png' }, + ]); + expect( + getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles + ).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]); + expect( + getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles + ).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]); + }); + + it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); + const okPath = join(tempDir, 'ok.txt'); + const missingPath = join(tempDir, 'missing.txt'); + writeFileSync(okPath, 'ok'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Uploads', + currentUrl: 'https://example.com/uploads', + dropzones: { + '#reject-dropzone': { accepted: false }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#reject-dropzone', files: [missingPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 905, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 906, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#missing-dropzone', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 907, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#reject-dropzone', files: [okPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); + expect(getResponseText(responses.find((message) => message.id === 905))).toContain( + 'pageIndex and pageId cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 906))).toContain( + 'element not found for selector: #missing-dropzone' + ); + expect(getResponseText(responses.find((message) => message.id === 907))).toContain( + 'drop target rejected files' + ); + }); + + it('drags an element to another element and to explicit coordinates', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Drag Page', + currentUrl: 'https://example.com/drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 240, + y: 60, + width: 120, + height: 80, + top: 60, + right: 360, + bottom: 140, + left: 240, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + { + id: 'page-2', + title: 'Second Drag Page', + currentUrl: 'https://example.com/drag-2', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 10, + y: 20, + width: 40, + height: 20, + top: 20, + right: 50, + bottom: 40, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 110, + y: 80, + width: 60, + height: 30, + top: 80, + right: 170, + bottom: 110, + left: 110, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 908, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#lane-b', steps: 3 }, + }, + }, + { + jsonrpc: '2.0', + id: 909, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetX: 420, targetY: 180, steps: 2 }, + }, + }, + { + jsonrpc: '2.0', + id: 916, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { pageId: 'page-2', selector: '#card-a', targetSelector: '#lane-b', steps: 1 }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged'); + expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180'); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b'); + expect(pages[0]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, + { type: 'mousePressed', x: 70, y: 70, button: 'left' }, + { type: 'mouseMoved', x: 147, y: 80, button: 'left' }, + { type: 'mouseMoved', x: 223, y: 90, button: 'left' }, + { type: 'mouseMoved', x: 300, y: 100, button: 'left' }, + { type: 'mouseReleased', x: 300, y: 100, button: 'left' }, + { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, + { type: 'mousePressed', x: 70, y: 70, button: 'left' }, + { type: 'mouseMoved', x: 245, y: 125, button: 'left' }, + { type: 'mouseMoved', x: 420, y: 180, button: 'left' }, + { type: 'mouseReleased', x: 420, y: 180, button: 'left' }, + ]); + expect(pages[1]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 30, y: 30, button: 'none' }, + { type: 'mousePressed', x: 30, y: 30, button: 'left' }, + { type: 'mouseMoved', x: 140, y: 95, button: 'left' }, + { type: 'mouseReleased', x: 140, y: 95, button: 'left' }, + ]); + }); + + it('rejects invalid browser_drag_element target combinations, page conflicts, and missing elements', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Drag Failures', + currentUrl: 'https://example.com/drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 910, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, + }, + }, + { + jsonrpc: '2.0', + id: 911, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#missing-source', targetX: 400, targetY: 200 }, + }, + }, + { + jsonrpc: '2.0', + id: 912, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#missing-target' }, + }, + }, + { + jsonrpc: '2.0', + id: 917, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 910))).toContain( + 'targetSelector and targetX/targetY cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 911))).toContain( + 'source element not found' + ); + expect(getResponseText(responses.find((message) => message.id === 912))).toContain( + 'target element not found' + ); + expect(getResponseText(responses.find((message) => message.id === 917))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + + it('runs minimal pointer sequences with browser_pointer_action', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Pointer Page', + currentUrl: 'https://example.com/pointer', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + { + id: 'page-2', + title: 'Second Pointer Page', + currentUrl: 'https://example.com/pointer-2', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 100, + y: 200, + width: 20, + height: 20, + top: 200, + right: 120, + bottom: 220, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 913, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down' }, + { type: 'move', x: 220, y: 160 }, + { type: 'pause', durationMs: 5 }, + { type: 'up' }, + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 918, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + pageId: 'page-2', + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down' }, + { type: 'up' }, + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed'); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed'); + expect(pages[0]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 50, y: 60, button: 'none' }, + { type: 'mousePressed', x: 50, y: 60, button: 'left' }, + { type: 'mouseMoved', x: 220, y: 160, button: 'left' }, + { type: 'mouseReleased', x: 220, y: 160, button: 'left' }, + ]); + expect(pages[1]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 110, y: 210, button: 'none' }, + { type: 'mousePressed', x: 110, y: 210, button: 'left' }, + { type: 'mouseReleased', x: 110, y: 210, button: 'left' }, + ]); + }); + + it('rejects invalid browser_pointer_action sequences and page conflicts', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Pointer Failures', + currentUrl: 'https://example.com/pointer', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 914, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'up' }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 915, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#missing-handle' }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 919, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + pageIndex: 0, + pageId: 'page-1', + actions: [{ type: 'move', x: 10, y: 10 }], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); + expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable'); + expect(getResponseText(responses.find((message) => message.id === 919))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + it('waits for a matching navigation event with browser_wait_for_event', async () => { const responses = await runMcpRequests( [ From 17a343405629178c1a082132069102b0d8f28e9c Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 12:44:22 +0800 Subject: [PATCH 21/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=20Pha?= =?UTF-8?q?se=2010A=20recording=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 recording session state、基础 recorder 注入与 capture 读取,打通 start/stop/get/clear 最小闭环。 并补充 pageId 路由、生命周期错误与 page close 停止录制覆盖。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 372 ++++++++++++++++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 356 +++++++++++++++++ 2 files changed, 728 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 7e80b71b..f0ce6120 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -62,6 +62,10 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -93,6 +97,10 @@ const TOOL_NAMES = [ TOOL_DRAG_FILES, TOOL_DRAG_ELEMENT, TOOL_POINTER_ACTION, + TOOL_START_RECORDING, + TOOL_STOP_RECORDING, + TOOL_GET_RECORDING, + TOOL_CLEAR_RECORDING, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -128,6 +136,9 @@ let selectedPageId = ''; let messageQueue = Promise.resolve(); let nextInterceptRuleCounter = 1; let nextDownloadCounter = 1; +let nextRecordingCounter = 1; +let activeRecordingSession = null; +let latestRecordingSession = null; const interceptRules = []; const recentRequests = []; const recentDownloads = []; @@ -709,6 +720,45 @@ function getTools() { 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_TAKE_SCREENSHOT, description: @@ -2230,6 +2280,162 @@ function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierce return lines.join('\n'); } +function createRecordingId() { + const recordingId = `rec_${String(nextRecordingCounter).padStart(4, '0')}`; + nextRecordingCounter += 1; + return recordingId; +} + +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 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'); +} + +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'); @@ -3858,6 +4064,19 @@ async function handleClosePage(toolArgs) { 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) { @@ -4142,6 +4361,127 @@ async function handleDragFiles(toolArgs) { }); } +function buildRecordingInstallExpression(recordingPayload) { + return `(() => { + const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))}); + globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = { + installed: true, + events: Array.isArray(recordingPayload.events) ? recordingPayload.events : [], + warnings: Array.isArray(recordingPayload.warnings) ? recordingPayload.warnings : [], + }; + 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); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + activeRecordingSession = { + recordingId: createRecordingId(), + pageId: page.id, + pageIndex, + startedAt: new Date().toISOString(), + stoppedAt: null, + status: 'recording', + steps: [], + warnings: [], + rawEvents: [], + }; + latestRecordingSession = activeRecordingSession; + + await installRecorderAndCapture(page); + activeRecordingSession.pageWebSocketDebuggerUrl = page.webSocketDebuggerUrl; + activeRecordingSession.captureInstalled = true; + + return formatRecordingSummary(activeRecordingSession); +} + +async function handleStopRecording() { + if (!activeRecordingSession) { + throw new Error('no active recording'); + } + await finalizeRecordingCapture(activeRecordingSession); + activeRecordingSession.status = 'stopped'; + activeRecordingSession.stoppedAt = new Date().toISOString(); + const stopped = activeRecordingSession; + activeRecordingSession = null; + latestRecordingSession = stopped; + return formatRecordingSummary(stopped); +} + +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'; +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -4330,6 +4670,38 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index fbae2d58..0921d759 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -132,6 +132,78 @@ type MockDragPlan = { recordedActions?: MockPointerActionRecord[]; }; +type MockRecordedEvent = + | { + kind: 'click'; + selector: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + button?: 'left' | 'middle' | 'right'; + clickCount?: number; + offsetX?: number; + offsetY?: number; + timestamp?: number; + } + | { + kind: 'type'; + selector: string; + text: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'press_key'; + key: string; + modifiers?: string[]; + timestamp?: number; + } + | { + kind: 'scroll'; + selector?: string; + deltaX?: number; + deltaY?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'drag_element'; + selector: string; + targetSelector?: string; + targetX?: number; + targetY?: number; + nth?: number; + targetNth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'pointer_action'; + actions: Array<{ + type: 'move' | 'down' | 'up' | 'pause'; + selector?: string; + x?: number; + y?: number; + button?: 'left' | 'middle' | 'right'; + durationMs?: number; + }>; + timestamp?: number; + }; + +type MockRecordingWarning = { + message: string; +}; + +type MockRecordingPlan = { + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + injectionError?: string; +}; + type MockFrameState = { selector: string; query?: Record; @@ -200,6 +272,7 @@ type MockPageState = { shadowRoots?: MockShadowRootState[]; dropzones?: Record; drag?: MockDragPlan; + recording?: MockRecordingPlan; events?: MockPageEventPlan; intercept?: MockInterceptState; screenshot?: { @@ -433,6 +506,36 @@ function parseParsedJsonArrayArgument(expression: string, key: string): T[] { 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 @@ -500,6 +603,11 @@ function pushPointerAction(page: MockPageState, action: MockPointerActionRecord) 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) { @@ -961,6 +1069,46 @@ function createMockBrowser(pagesInput: MockPageState[]) { } const expression = String(message.params?.expression || ''); + const recordingPayload = parseParsedJsonObjectArgument<{ + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + }>(expression, 'recordingPayload'); + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ =')) { + const plan = getMockRecordingPlan(page); + if (plan.injectionError) { + reply({ + result: { + type: 'object', + subtype: 'error', + description: plan.injectionError, + }, + }); + return; + } + + if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { + plan.events = recordingPayload.events || []; + plan.warnings = recordingPayload.warnings || []; + } + + reply({ result: { type: 'object', value: { installed: true } } }); + return; + } + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { + const plan = getMockRecordingPlan(page); + reply({ + result: { + type: 'object', + value: { + events: plan.events || [], + warnings: plan.warnings || [], + }, + }, + }); + return; + } if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) { const selector = parseJsonArgument(expression, 'selector') || ''; @@ -1677,6 +1825,10 @@ describe('ccs-browser MCP server', () => { 'browser_drag_files', 'browser_drag_element', 'browser_pointer_action', + 'browser_start_recording', + 'browser_stop_recording', + 'browser_get_recording', + 'browser_clear_recording', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1785,6 +1937,19 @@ describe('ccs-browser MCP server', () => { 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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -5438,6 +5603,197 @@ describe('ccs-browser MCP server', () => { ); }); + 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('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('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'); From 5288387151e150b37990e6098d75c957c6d7e0ac Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 12:50:15 +0800 Subject: [PATCH 22/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20Pha?= =?UTF-8?q?se=2010A=20=E5=BD=95=E5=88=B6=E5=8A=A8=E4=BD=9C=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 recording 安装阶段的真实页面监听与注入失败回滚,避免空录制和损坏会话状态。 并完善高层 step 规范化与相关回归覆盖。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 92 +++++++++++++++++-- .../unit/hooks/ccs-browser-mcp-server.test.ts | 22 +++++ 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index f0ce6120..e79e2f52 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -4364,10 +4364,86 @@ async function handleDragFiles(toolArgs) { 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: Array.isArray(recordingPayload.events) ? recordingPayload.events : [], - warnings: Array.isArray(recordingPayload.warnings) ? recordingPayload.warnings : [], + 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 }; })()`; @@ -4435,7 +4511,7 @@ async function handleStartRecording(toolArgs) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } - activeRecordingSession = { + const session = { recordingId: createRecordingId(), pageId: page.id, pageIndex, @@ -4445,14 +4521,16 @@ async function handleStartRecording(toolArgs) { steps: [], warnings: [], rawEvents: [], + pageWebSocketDebuggerUrl: page.webSocketDebuggerUrl, + captureInstalled: false, }; - latestRecordingSession = activeRecordingSession; await installRecorderAndCapture(page); - activeRecordingSession.pageWebSocketDebuggerUrl = page.webSocketDebuggerUrl; - activeRecordingSession.captureInstalled = true; + session.captureInstalled = true; + activeRecordingSession = session; + latestRecordingSession = session; - return formatRecordingSummary(activeRecordingSession); + return formatRecordingSummary(session); } async function handleStopRecording() { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 0921d759..6c3c0bcf 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -5695,6 +5695,28 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); }); + it('rolls back recording state when recorder injection fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Recording Page', + currentUrl: 'https://example.com/broken-recording', + recording: { + injectionError: 'recording injection failed', + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed'); + expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available'); + }); + it('normalizes type, press_key, scroll, and warnings in a recording result', async () => { const responses = await runMcpRequests( [ From b477a75d0e110d4611babefcc34aee4a77be5aeb Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 13:19:03 +0800 Subject: [PATCH 23/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=20Pha?= =?UTF-8?q?se=2010A=20recording=20=E8=83=BD=E5=8A=9B=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的 Phase 9 与 Phase 10A 工具面、能力边界与 session-local 约束。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 6ef4ea2f..53574429 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -23,12 +23,12 @@ enabled. A recent Chrome update alone is not sufficient. The managed `ccs-browser` runtime currently exposes seven tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` -- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`, `browser_drag_element`, `browser_pointer_action` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` - **Network interception**: `browser_add_intercept_rule`, `browser_remove_intercept_rule`, `browser_list_intercept_rules`, `browser_list_requests` -- **File transfer**: `browser_set_download_behavior`, `browser_list_downloads`, `browser_cancel_download`, `browser_set_file_input` +- **File transfer**: `browser_set_download_behavior`, `browser_list_downloads`, `browser_cancel_download`, `browser_set_file_input`, `browser_drag_files` Notable Phase 1 capability details: @@ -84,6 +84,23 @@ Phase 8 capability details: - download controls are browser-scoped because they map to Chrome's Browser domain; file input uploads remain page-scoped selector actions - download behavior and recent download summaries are session-local runtime state and are not persisted across runtime restarts +Phase 9 capability details: + +- `browser_drag_files` drags one or more local files onto a matched drop target by constructing page-side `File` objects and a `DataTransfer` payload, then dispatching `dragenter`, `dragover`, and `drop` +- `browser_drag_files` reuses the existing selected-page, `pageIndex`, `pageId`, `nth`, `frameSelector`, and `pierceShadow` selector-routing semantics, and returns only a result summary rather than file contents +- `browser_drag_element` drags a matched source element either to another matched target element or to explicit coordinates using browser-level mouse events +- `browser_drag_element` and `browser_pointer_action` both honor selected-page routing plus explicit `pageIndex` or `pageId`; `pageIndex` and `pageId` remain mutually exclusive +- `browser_pointer_action` is a limited fallback primitive for `move`, `down`, `up`, and `pause`; it is not a recording format, scripting DSL, or multi-pointer gesture system +- Phase 9 remains session-local and does not add recording, replay, orchestration, touch gestures, or cross-page drag semantics + +Phase 10A capability details: + +- `browser_start_recording`, `browser_stop_recording`, `browser_get_recording`, and `browser_clear_recording` add a minimal recording workflow on top of the existing Browser MCP tool surface +- recording state is session-local and is not persisted across runtime restarts +- Phase 10A records structured steps only; replay and orchestration remain out of scope for this phase +- high-level recording prefers `click`, `type`, `press_key`, `scroll`, and `drag_element`; unresolved interactions may be represented as `pointer_action` or warnings +- only one active recording session is allowed per MCP runtime; if the recorded page is closed, the session stops and keeps the captured result plus a warning summary + Minimal multi-tab workflow examples: ```json @@ -131,9 +148,12 @@ A common hover-debug workflow is: Scoped selector notes: -- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible +- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, `browser_take_element_screenshot`, `browser_set_file_input`, `browser_drag_files`, `browser_drag_element`, and selector-based `browser_pointer_action` moves accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, and advanced boolean matcher groups are still out of scope +- Phase 10A recording keeps reusing the existing selected-page routing model; recorded steps may preserve selector context such as `frameSelector` and `pierceShadow` when the target can be resolved stably +- `browser_drag_element` reuses the same source/target selector scope for element-to-element drags; coordinate targets remain page-viewport coordinates +- `browser_pointer_action` selector-based `move` steps resolve a matched element center before dispatching browser-level mouse events +- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, advanced boolean matcher groups, recording/replay orchestration, and touch/multi-pointer gestures are still out of scope Example event wait: From 9d21e83d46da314d78189cbb483c0689b090738e Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 13:59:34 +0800 Subject: [PATCH 24/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20Pha?= =?UTF-8?q?se=2010B=20replay=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增最小 replay 闭环,支持消费 10A 结构化 steps 并顺序回放常见动作。 同时补充输入校验、失败定位与 replay 边界覆盖。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 307 ++++++++++++++++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 284 ++++++++++++++++ 2 files changed, 591 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index e79e2f52..ea954cdd 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -66,6 +66,9 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -101,6 +104,9 @@ const TOOL_NAMES = [ TOOL_STOP_RECORDING, TOOL_GET_RECORDING, TOOL_CLEAR_RECORDING, + TOOL_START_REPLAY, + TOOL_GET_REPLAY, + TOOL_CANCEL_REPLAY, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -139,6 +145,9 @@ let nextDownloadCounter = 1; let nextRecordingCounter = 1; let activeRecordingSession = null; let latestRecordingSession = null; +let nextReplayCounter = 1; +let activeReplaySession = null; +let latestReplaySession = null; const interceptRules = []; const recentRequests = []; const recentDownloads = []; @@ -759,6 +768,38 @@ function getTools() { 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_TAKE_SCREENSHOT, description: @@ -2286,6 +2327,12 @@ function createRecordingId() { 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'); @@ -2304,6 +2351,27 @@ function formatRecordingSummary(session) { ].join('\n'); } +function requireLatestReplay() { + if (!latestReplaySession) { + throw new Error('no replay available'); + } + return latestReplaySession; +} + +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 formatRecordingDetail(session) { const lines = [formatRecordingSummary(session), 'steps:']; if (session.steps.length === 0) { @@ -2344,6 +2412,35 @@ function formatRecordingDetail(session) { 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'); + } +} + function normalizeRecordedEvent(pageId, event) { if (!event || typeof event !== 'object') { return null; @@ -4560,6 +4657,192 @@ async function handleClearRecording() { 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) { + 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; + } +} + +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); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const steps = requireReplaySteps(toolArgs); + const session = { + replayId: createReplayId(), + pageId: page.id, + pageIndex, + status: 'running', + stepCount: steps.length, + completedSteps: 0, + currentStepIndex: 0, + failedStepIndex: null, + error: null, + steps, + }; + + latestReplaySession = session; + activeReplaySession = session; + + try { + await executeReplaySteps(session); + session.status = 'completed'; + } catch (error) { + session.status = 'failed'; + session.failedStepIndex = session.currentStepIndex; + session.error = error instanceof Error ? error.message : String(error); + } + + activeReplaySession = null; + latestReplaySession = session; + return formatReplaySummary(session); +} + +async function handleGetReplay() { + const session = requireLatestReplay(); + return formatReplaySummary(session); +} + +async function handleCancelReplay() { + if (!activeReplaySession) { + throw new Error('no active replay'); + } + activeReplaySession.status = 'canceled'; + const canceled = activeReplaySession; + activeReplaySession = null; + latestReplaySession = canceled; + return formatReplaySummary(canceled); +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -4780,6 +5063,30 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 6c3c0bcf..b01ad4d2 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -435,6 +435,10 @@ function getResponseText(message: Record | undefined): string { return result.content?.[0]?.text || ''; } +function createReplayStep(step: Record): Record { + return step; +} + function parseJsonArgument(expression: string, key: string): string | undefined { const marker = `const ${key} = JSON.parse(`; const start = expression.indexOf(marker); @@ -1829,6 +1833,9 @@ describe('ccs-browser MCP server', () => { 'browser_stop_recording', 'browser_get_recording', 'browser_clear_recording', + 'browser_start_replay', + 'browser_get_replay', + 'browser_cancel_replay', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1950,6 +1957,17 @@ describe('ccs-browser MCP server', () => { 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 queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -5816,6 +5834,272 @@ describe('ccs-browser MCP server', () => { expect(text).toContain('recording stopped because target page was closed'); }); + it('starts a replay, reports progress, and completes basic steps', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Page', + currentUrl: 'https://example.com/replay', + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + scroll: { + '#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1101, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + nth: 0, + args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 }, + }), + createReplayStep({ + type: 'type', + pageId: 'page-1', + selector: '#email', + nth: 0, + args: { text: 'walker@example.com' }, + }), + createReplayStep({ + type: 'scroll', + pageId: 'page-1', + selector: '#results', + args: { deltaX: 0, deltaY: 240 }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1102, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3'); + expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed'); + }); + + it('rejects invalid replay payloads before execution starts', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }], + [ + { + jsonrpc: '2.0', + id: 1111, + method: 'tools/call', + params: { name: 'browser_start_replay', arguments: { steps: [] } }, + }, + { + jsonrpc: '2.0', + id: 1112, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1113, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })], + pageId: 'page-1', + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type'); + expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch'); + }); + + it('fails replay on the first failing step and reports failedStepIndex', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Replay Failure Page', + currentUrl: 'https://example.com/replay-failure', + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1114, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }), + createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1115, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1115)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedStepIndex: 1'); + expect(text).toContain('element index 0 is out of range for selector: #missing'); + }); + + it('replays drag_element and pointer_action steps successfully', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Drag Page', + currentUrl: 'https://example.com/replay-drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 240, + y: 60, + width: 120, + height: 80, + top: 60, + right: 360, + bottom: 140, + left: 240, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1121, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'drag_element', + pageId: 'page-1', + selector: '#card-a', + args: { targetSelector: '#lane-b' }, + }), + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', x: 10, y: 20 }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'left' }, + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2'); + }); + it('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'); From 2099145e2cf0dd530cb144fb296c8e7e66e848c4 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 14:06:15 +0800 Subject: [PATCH 25/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=20Pha?= =?UTF-8?q?se=2010B=20replay=20=E8=83=BD=E5=8A=9B=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的 Phase 10B replay 工具面、顺序执行语义与 session-local 边界。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 53574429..098028c8 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -23,7 +23,7 @@ enabled. A recent Chrome update alone is not sufficient. The managed `ccs-browser` runtime currently exposes seven tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` -- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`, `browser_drag_element`, `browser_pointer_action` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`, `browser_drag_element`, `browser_pointer_action`, `browser_start_replay`, `browser_get_replay`, `browser_cancel_replay` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` @@ -101,6 +101,14 @@ Phase 10A capability details: - high-level recording prefers `click`, `type`, `press_key`, `scroll`, and `drag_element`; unresolved interactions may be represented as `pointer_action` or warnings - only one active recording session is allowed per MCP runtime; if the recorded page is closed, the session stops and keeps the captured result plus a warning summary +Phase 10B capability details: + +- `browser_start_replay`, `browser_get_replay`, and `browser_cancel_replay` add a minimal replay workflow that consumes Phase 10A-compatible structured steps +- replay state is session-local and is not persisted across runtime restarts +- replay executes steps sequentially and stops on the first failure +- replay reuses existing Browser MCP action semantics rather than introducing a new replay DSL +- only one active replay session is allowed per MCP runtime; orchestration remains out of scope for Phase 10B + Minimal multi-tab workflow examples: ```json From 77855035f4d0f6cbd37209b25b11c6cc5360b4cd Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 15:00:15 +0800 Subject: [PATCH 26/47] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20Pha?= =?UTF-8?q?se=2010C=20orchestration=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增固定流程块 orchestration 闭环,支持 wait_then_click、wait_then_type、run_replay_sequence 与 assert_query。 并补充 block 级失败定位与相关回归覆盖。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 229 +++++++++++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 310 ++++++++++++++++++ 2 files changed, 539 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index ea954cdd..c7b8cd55 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -69,6 +69,9 @@ 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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -107,6 +110,9 @@ const TOOL_NAMES = [ TOOL_START_REPLAY, TOOL_GET_REPLAY, TOOL_CANCEL_REPLAY, + TOOL_START_ORCHESTRATION, + TOOL_GET_ORCHESTRATION, + TOOL_CANCEL_ORCHESTRATION, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -148,6 +154,9 @@ let latestRecordingSession = null; let nextReplayCounter = 1; let activeReplaySession = null; let latestReplaySession = null; +let nextOrchestrationCounter = 1; +let activeOrchestrationSession = null; +let latestOrchestrationSession = null; const interceptRules = []; const recentRequests = []; const recentDownloads = []; @@ -800,6 +809,38 @@ function getTools() { 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_TAKE_SCREENSHOT, description: @@ -2351,6 +2392,12 @@ function formatRecordingSummary(session) { ].join('\n'); } +function createOrchestrationId() { + const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`; + nextOrchestrationCounter += 1; + return orchestrationId; +} + function requireLatestReplay() { if (!latestReplaySession) { throw new Error('no replay available'); @@ -2358,6 +2405,13 @@ function requireLatestReplay() { return latestReplaySession; } +function requireLatestOrchestration() { + if (!latestOrchestrationSession) { + throw new Error('no orchestration available'); + } + return latestOrchestrationSession; +} + function formatReplaySummary(session) { return [ `replayId: ${session.replayId}`, @@ -2372,6 +2426,20 @@ function formatReplaySummary(session) { ].join('\n'); } +function formatOrchestrationSummary(session) { + return [ + `orchestrationId: ${session.orchestrationId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `blockCount: ${session.blockCount}`, + `completedBlocks: ${session.completedBlocks}`, + `currentBlockIndex: ${session.currentBlockIndex}`, + `failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`, + `error: ${session.error || 'none'}`, + `status: ${session.status}`, + ].join('\n'); +} + function formatRecordingDetail(session) { const lines = [formatRecordingSummary(session), 'steps:']; if (session.steps.length === 0) { @@ -2441,6 +2509,31 @@ function validateReplayStep(step, replayPageId) { } } +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', +]); + +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 normalizeRecordedEvent(pageId, event) { if (!event || typeof event !== 'object') { return null; @@ -4843,6 +4936,118 @@ async function handleCancelReplay() { return formatReplaySummary(canceled); } +async function executeOrchestrationBlock(session, block) { + 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 replayResultText = await handleStartReplay({ pageId: session.pageId, steps: block.args.steps }); + if (replayResultText.includes('status: failed')) { + throw new Error(replayResultText); + } + return; + } + + if (block.type === 'assert_query') { + const queryText = await handleQuery({ pageId: session.pageId, ...block.args.query }); + const expectedField = String(block.args.assert.field || ''); + const expectedValue = String(block.args.assert.equals || ''); + if (!queryText.includes(`${expectedField}: ${expectedValue}`)) { + throw new Error(`assert_query failed for field ${expectedField}`); + } + return; + } + + throw new Error('unsupported orchestration block type'); +} + +async function executeOrchestrationBlocks(session) { + for (let index = 0; index < session.blocks.length; index += 1) { + const block = session.blocks[index]; + session.currentBlockIndex = index; + validateOrchestrationBlock(block); + await executeOrchestrationBlock(session, block); + session.completedBlocks = index + 1; + } +} + +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); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const blocks = requireOrchestrationBlocks(toolArgs); + const session = { + orchestrationId: createOrchestrationId(), + pageId: page.id, + pageIndex, + status: 'running', + blockCount: blocks.length, + completedBlocks: 0, + currentBlockIndex: 0, + failedBlockIndex: null, + error: null, + blocks, + }; + + latestOrchestrationSession = session; + activeOrchestrationSession = session; + + try { + await executeOrchestrationBlocks(session); + session.status = 'completed'; + } catch (error) { + session.status = 'failed'; + session.failedBlockIndex = session.currentBlockIndex; + session.error = error instanceof Error ? error.message : String(error); + } + + activeOrchestrationSession = null; + latestOrchestrationSession = session; + return formatOrchestrationSummary(session); +} + +async function handleGetOrchestration() { + const session = requireLatestOrchestration(); + return formatOrchestrationSummary(session); +} + +async function handleCancelOrchestration() { + if (!activeOrchestrationSession) { + throw new Error('no active orchestration'); + } + activeOrchestrationSession.status = 'canceled'; + const canceled = activeOrchestrationSession; + activeOrchestrationSession = null; + latestOrchestrationSession = canceled; + return formatOrchestrationSummary(canceled); +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -5087,6 +5292,30 @@ async function handleToolCall(message) { 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_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index b01ad4d2..275a8801 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -439,6 +439,10 @@ function createReplayStep(step: Record): Record): Record { + return block; +} + function parseJsonArgument(expression: string, key: string): string | undefined { const marker = `const ${key} = JSON.parse(`; const start = expression.indexOf(marker); @@ -1836,6 +1840,9 @@ describe('ccs-browser MCP server', () => { 'browser_start_replay', 'browser_get_replay', 'browser_cancel_replay', + 'browser_start_orchestration', + 'browser_get_orchestration', + 'browser_cancel_orchestration', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1968,6 +1975,17 @@ describe('ccs-browser MCP server', () => { const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay'); expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' }); + const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); + expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); + expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + + const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); + expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + const queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -6100,6 +6118,298 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2'); }); + it('starts orchestration, reports progress, and completes a wait_then_click flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Page', + currentUrl: 'https://example.com/orchestration', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1201, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { + selector: '#menu', + condition: { kind: 'visibility' }, + timeoutMs: 1000, + }, + click: { + selector: '#menu', + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); + }); + + it('completes a wait_then_type flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Type Orchestration Page', + currentUrl: 'https://example.com/type-orchestration', + wait: { + selectorSnapshots: { + '#email': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1203, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_type', + args: { + wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + type: { selector: '#email', text: 'walker@example.com', clearFirst: true }, + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1'); + }); + + it('completes a run_replay_sequence block', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Sequence Orchestration Page', + currentUrl: 'https://example.com/orchestration-replay', + click: { '#submit': {} }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1211, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed'); + }); + + it('completes an assert_query block', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1212, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assert: { field: 'innerText', equals: 'ready' }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed'); + }); + + it('fails orchestration on the first failing block', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }], + [ + { + jsonrpc: '2.0', + id: 1213, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assert: { field: 'exists', equals: 'true' }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1214, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1214)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + }); + it('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'); From c708bde817bc11ab87065e514078b2d2f725db9b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 15:10:55 +0800 Subject: [PATCH 27/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=20Pha?= =?UTF-8?q?se=2010C=20orchestration=20=E8=83=BD=E5=8A=9B=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的 Phase 10C orchestration 工具面、固定 block 语义与 session-local 边界。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 098028c8..2b10c23f 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -23,7 +23,7 @@ enabled. A recent Chrome update alone is not sufficient. The managed `ccs-browser` runtime currently exposes seven tool groups: - **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot` -- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`, `browser_drag_element`, `browser_pointer_action`, `browser_start_replay`, `browser_get_replay`, `browser_cancel_replay` +- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`, `browser_drag_element`, `browser_pointer_action`, `browser_start_replay`, `browser_get_replay`, `browser_cancel_replay`, `browser_start_orchestration`, `browser_get_orchestration`, `browser_cancel_orchestration` - **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot` - **Readiness and page evaluation**: `browser_wait_for`, `browser_eval` - **Event observation**: `browser_wait_for_event` @@ -109,6 +109,14 @@ Phase 10B capability details: - replay reuses existing Browser MCP action semantics rather than introducing a new replay DSL - only one active replay session is allowed per MCP runtime; orchestration remains out of scope for Phase 10B +Phase 10C capability details: + +- `browser_start_orchestration`, `browser_get_orchestration`, and `browser_cancel_orchestration` add a minimal fixed-block orchestration workflow +- orchestration state is session-local and is not persisted across runtime restarts +- fixed orchestration blocks reuse existing wait / replay / action / query semantics rather than introducing a general DSL +- orchestration runs blocks sequentially and stops on the first failure +- block nesting, branching, loops, and workflow-engine behaviors remain out of scope + Minimal multi-tab workflow examples: ```json From 4af967559cd985b0829bbb197edcae1bb264bdc0 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 17:39:19 +0800 Subject: [PATCH 28/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=BC=BA=20ass?= =?UTF-8?q?ert=5Fquery=20=E6=96=AD=E8=A8=80=E4=B8=8E=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 assert_query 增加 assertions 数组、更多比较操作和结构化失败 diagnostics。 并补充多断言、类型错误和数值比较的回归覆盖。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 158 ++++++++++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 264 ++++++++++++++++++ 2 files changed, 415 insertions(+), 7 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index c7b8cd55..a61cd54d 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2427,7 +2427,7 @@ function formatReplaySummary(session) { } function formatOrchestrationSummary(session) { - return [ + const lines = [ `orchestrationId: ${session.orchestrationId}`, `pageId: ${session.pageId}`, `pageIndex: ${session.pageIndex}`, @@ -2437,7 +2437,15 @@ function formatOrchestrationSummary(session) { `failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`, `error: ${session.error || 'none'}`, `status: ${session.status}`, - ].join('\n'); + ]; + 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'); } function formatRecordingDetail(session) { @@ -2517,6 +2525,133 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ 'assert_query', ]); +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 requireOrchestrationBlocks(toolArgs) { const blocks = Array.isArray(toolArgs.blocks) ? toolArgs.blocks : null; if (!blocks || blocks.length === 0) { @@ -4965,10 +5100,18 @@ async function executeOrchestrationBlock(session, block) { if (block.type === 'assert_query') { const queryText = await handleQuery({ pageId: session.pageId, ...block.args.query }); - const expectedField = String(block.args.assert.field || ''); - const expectedValue = String(block.args.assert.equals || ''); - if (!queryText.includes(`${expectedField}: ${expectedValue}`)) { - throw new Error(`assert_query failed for field ${expectedField}`); + 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; } @@ -5024,7 +5167,8 @@ async function handleStartOrchestration(toolArgs) { } catch (error) { session.status = 'failed'; session.failedBlockIndex = session.currentBlockIndex; - session.error = error instanceof Error ? error.message : String(error); + session.errorDetails = formatOrchestrationError(error); + session.error = session.errorDetails.message || (error instanceof Error ? error.message : String(error)); } activeOrchestrationSession = null; diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 275a8801..fa7a42a1 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -6410,6 +6410,270 @@ describe('ccs-browser MCP server', () => { expect(text).toContain('failedBlockIndex: 0'); }); + it('passes assert_query with multiple structured assertions', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1301, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['exists', 'innerText', 'opacity'] }, + assertions: [ + { field: 'exists', op: 'equals', value: true }, + { field: 'innerText', op: 'contains', value: 'ready' }, + { field: 'opacity', op: 'gte', value: 0.9 }, + ], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1301))).toContain('status: completed'); + }); + + it('returns failedAssertionIndex, expected, and actual when assert_query fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Failure Page', + currentUrl: 'https://example.com/assert-failure', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'loading', + textContent: 'loading', + display: 'block', + visibility: 'visible', + opacity: '0.42', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1302, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText', 'opacity'] }, + assertions: [ + { field: 'innerText', op: 'contains', value: 'ready' }, + { field: 'opacity', op: 'gte', value: 0.9 }, + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1303, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1303)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedAssertionIndex: 0'); + expect(text).toContain('field: innerText'); + expect(text).toContain('expected: "ready"'); + expect(text).toContain('actual: "loading"'); + }); + + it('fails when numeric comparison is used on a non-number-like field or when op is unsupported', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Type Error Page', + currentUrl: 'https://example.com/assert-type-error', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'loading', + textContent: 'loading', + opacity: '0.42', + display: 'block', + visibility: 'visible', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1304, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'gte', value: 1 }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1305, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['opacity'] }, + assertions: [{ field: 'opacity', op: 'matches', value: '.*' }], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1304))).toContain('numeric comparison expects a number-like field: innerText'); + expect(getResponseText(responses.find((message) => message.id === 1305))).toContain('unsupported assertion operator'); + }); + + it('passes numeric gte and lt assertions', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Numeric Assertion Page', + currentUrl: 'https://example.com/assert-numeric', + query: { + '#status': { + exists: true, + connected: true, + opacity: '0.95', + display: 'block', + visibility: 'visible', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1306, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['opacity'] }, + assertions: [ + { field: 'opacity', op: 'gte', value: 0.9 }, + { field: 'opacity', op: 'lt', value: 1.0 }, + ], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1306))).toContain('status: completed'); + }); + it('drags local files onto normal, frame, and shadow dropzones', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); const invoicePath = join(tempDir, 'invoice.pdf'); From 26baa019e3c54a0cad6cafab7a6002e04e6b0dbe Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 17:51:26 +0800 Subject: [PATCH 29/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=2011A?= =?UTF-8?q?=20=E6=96=AD=E8=A8=80=E4=B8=8E=E8=AF=8A=E6=96=AD=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的结构化 assertions、比较操作与失败诊断输出说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 2b10c23f..d164982d 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -117,6 +117,13 @@ Phase 10C capability details: - orchestration runs blocks sequentially and stops on the first failure - block nesting, branching, loops, and workflow-engine behaviors remain out of scope +Phase 11A capability details: + +- `assert_query` now supports structured `assertions[]` with multiple field checks in one block +- supported operators are `equals`, `contains`, `gt`, `gte`, `lt`, and `lte` +- assertion failures now include clearer diagnostics such as failed assertion index, field, expected value, and actual value +- Phase 11A continues to reuse `browser_query` as the data source rather than introducing a new diagnostics runtime + Minimal multi-tab workflow examples: ```json From 0038b063ac2eb98255d09e0755096d94bca70813 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 18:55:24 +0800 Subject: [PATCH 30/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=20seq?= =?UTF-8?q?uence=20=E7=BB=84=E5=90=88=E5=9D=97=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增单层 sequence 容器以组合现有 orchestration blocks,并补充 sequence 内失败定位能力。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 39 ++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 335 ++++++++++++++++++ 2 files changed, 374 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a61cd54d..0ed628a7 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2438,6 +2438,9 @@ function formatOrchestrationSummary(session) { `error: ${session.error || 'none'}`, `status: ${session.status}`, ]; + 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}`); @@ -2523,6 +2526,7 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ 'wait_for_then_press_key', 'run_replay_sequence', 'assert_query', + 'sequence', ]); const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']); @@ -2669,6 +2673,21 @@ function validateOrchestrationBlock(block) { } } +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 normalizeRecordedEvent(pageId, event) { if (!event || typeof event !== 'object') { return null; @@ -5116,9 +5135,28 @@ async function executeOrchestrationBlock(session, block) { 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); + for (let index = 0; index < steps.length; index += 1) { + const step = steps[index]; + validateSequenceStep(step); + try { + await executeOrchestrationBlock(session, step); + } catch (error) { + session.failedSequenceStepIndex = index; + throw error; + } + } +} + async function executeOrchestrationBlocks(session) { for (let index = 0; index < session.blocks.length; index += 1) { const block = session.blocks[index]; @@ -5154,6 +5192,7 @@ async function handleStartOrchestration(toolArgs) { completedBlocks: 0, currentBlockIndex: 0, failedBlockIndex: null, + failedSequenceStepIndex: null, error: null, blocks, }; diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index fa7a42a1..ca1ee265 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -6410,6 +6410,341 @@ describe('ccs-browser MCP server', () => { 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( [ From 78f3879e19adfc90464fb2aca00a218b5efe55d5 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 19:06:03 +0800 Subject: [PATCH 31/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=2011B?= =?UTF-8?q?=20sequence=20=E8=83=BD=E5=8A=9B=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的 sequence 组合块、单层约束与失败定位说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index d164982d..6e7a4f99 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -124,6 +124,14 @@ Phase 11A capability details: - assertion failures now include clearer diagnostics such as failed assertion index, field, expected value, and actual value - Phase 11A continues to reuse `browser_query` as the data source rather than introducing a new diagnostics runtime +Phase 11B capability details: + +- `sequence` adds a single-layer grouping container on top of fixed orchestration blocks +- sequence steps reuse existing block semantics rather than introducing a DSL +- sequence runs steps sequentially and stops on the first failure +- nested sequence blocks remain out of scope +- sequence failures can report both top-level block failure and sequence-local step failure + Minimal multi-tab workflow examples: ```json From 101396f728dea649e1cb7e0d02e2a9ac8e66729a Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 19:35:52 +0800 Subject: [PATCH 32/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=20orc?= =?UTF-8?q?hestration=20=E6=89=A7=E8=A1=8C=E7=AD=96=E7=95=A5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 block 与 sequence step 增加最小 continueOnError 语义,并补充 failures 汇总输出。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 49 +++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 269 ++++++++++++++++++ 2 files changed, 316 insertions(+), 2 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 0ed628a7..817759d9 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2433,11 +2433,22 @@ function formatOrchestrationSummary(session) { `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}`); } @@ -2656,6 +2667,12 @@ function formatOrchestrationError(error) { 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) { @@ -5151,7 +5168,17 @@ async function executeSequenceSteps(session, block) { 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 error; } } @@ -5162,8 +5189,26 @@ async function executeOrchestrationBlocks(session) { const block = session.blocks[index]; session.currentBlockIndex = index; validateOrchestrationBlock(block); - await executeOrchestrationBlock(session, block); - session.completedBlocks = index + 1; + try { + await executeOrchestrationBlock(session, block); + session.completedBlocks = index + 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (session.failedSequenceStepIndex === null || session.failedSequenceStepIndex === undefined) { + pushOrchestrationFailure(session, { + blockIndex: index, + sequenceStepIndex: null, + type: block.type, + message, + }); + } + if (block.continueOnError === true) { + session.failedSequenceStepIndex = null; + session.completedBlocks = index + 1; + continue; + } + throw error; + } } } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index ca1ee265..31618c4a 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -7009,6 +7009,275 @@ describe('ccs-browser MCP server', () => { 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('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('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('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + 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'); From d0e6087126fdd9b5a0915229a2c6fd5395e9495c Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 19:45:47 +0800 Subject: [PATCH 33/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=2011C?= =?UTF-8?q?=20=E6=89=A7=E8=A1=8C=E7=AD=96=E7=95=A5=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的 continueOnError、失败汇总与最小执行策略边界说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 6e7a4f99..f9366389 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -132,6 +132,13 @@ Phase 11B capability details: - nested sequence blocks remain out of scope - sequence failures can report both top-level block failure and sequence-local step failure +Phase 11C capability details: + +- blocks and sequence steps can opt into `continueOnError: true` +- orchestration can continue after selected failures instead of always failing fast +- final results now include a lightweight `failures[]` summary and failed counts +- 11C intentionally stops short of retry, timeout override, severity, and other larger policy systems + Minimal multi-tab workflow examples: ```json From 2f167d8445e294cadb6be8e1190a0d33188f0b22 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 20:31:51 +0800 Subject: [PATCH 34/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0=20art?= =?UTF-8?q?ifact=20=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 recording、replay、orchestration 的本地 JSON artifact 导出、导入、列出与删除能力。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 217 ++++++++++++++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 233 ++++++++++++++++++ 2 files changed, 450 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 817759d9..90dd5812 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -72,6 +72,10 @@ 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'; @@ -113,6 +117,10 @@ const TOOL_NAMES = [ 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, @@ -841,6 +849,52 @@ function getTools() { 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: @@ -2392,6 +2446,52 @@ function formatRecordingSummary(session) { ].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 }); + } + return baseDir; +} + +function getArtifactPath(name) { + return path.join(getArtifactDir(), `${name}.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 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; @@ -5265,6 +5365,91 @@ async function handleGetOrchestration() { return formatOrchestrationSummary(session); } +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 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 = requireNonEmptyString(toolArgs.name, '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); + fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2), 'utf8'); + 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 = requireNonEmptyString(toolArgs.name, 'name'); + deleteArtifactByName(name); + return `name: ${name}\nstatus: deleted`; +} + async function handleCancelOrchestration() { if (!activeOrchestrationSession) { throw new Error('no active orchestration'); @@ -5544,6 +5729,38 @@ async function handleToolCall(message) { 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, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 31618c4a..60fc5493 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1843,6 +1843,10 @@ describe('ccs-browser MCP server', () => { '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', @@ -1986,6 +1990,19 @@ describe('ccs-browser MCP server', () => { 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', @@ -7278,6 +7295,222 @@ describe('ccs-browser MCP server', () => { 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 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' } } }, + ] + ); + + 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'); + }); + 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'); From 3c791a467fba9605da341b10bacda460564d9759 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 20:42:10 +0800 Subject: [PATCH 35/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=2011D?= =?UTF-8?q?=20=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的 artifact JSON、导入导出与本地持久化边界说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index f9366389..a11285ff 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -139,6 +139,14 @@ Phase 11C capability details: - final results now include a lightweight `failures[]` summary and failed counts - 11C intentionally stops short of retry, timeout override, severity, and other larger policy systems +Phase 11D capability details: + +- `browser_export_artifact`, `browser_import_artifact`, `browser_list_artifacts`, and `browser_delete_artifact` add a local JSON persistence workflow +- supported artifact kinds are `recording`, `replay`, and `orchestration` +- imported artifacts restore session-local state only and are not executed automatically +- artifact files carry `kind`, `version`, `name`, and `payload` +- cloud sync, sharing, and complex template management remain out of scope + Minimal multi-tab workflow examples: ```json From 1833ed3ad612c290226208e6557eacb282bfd6b2 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 21:03:33 +0800 Subject: [PATCH 36/47] =?UTF-8?q?feat(browser):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=B7=A8=20page=20orchestration=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增显式跨页 orchestration block,支持开页、切页、关页后执行单页逻辑。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 44 +++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 258 ++++++++++++++++++ 2 files changed, 302 insertions(+) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 90dd5812..a26d9ce1 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -2638,6 +2638,15 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ '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']); @@ -2805,6 +2814,16 @@ function validateSequenceStep(step) { } } +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; @@ -5208,6 +5227,31 @@ async function handleCancelReplay() { } 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 }); diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 60fc5493..fdfec7a0 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -7511,6 +7511,264 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); }); + it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1701, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 1 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); + }); + + it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1702, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'open_page_then_run', + args: { + open: { url: 'https://example.com/docs' }, + run: { + type: 'wait_for_then_press_key', + args: { + wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, + pressKey: { key: 'Enter' }, + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); + }); + + it('runs close_page_then_continue and then continues with the remaining blocks', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, + { + jsonrpc: '2.0', + id: 1704, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'close_page_then_continue', + args: { close: { pageId: 'page-2' } }, + }), + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + continueOnError: true, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); + }); + + it('rejects missing run payloads and nested cross-page blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1705, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1706, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: createOrchestrationBlock({ + type: 'open_page_then_run', + args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, + }), + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); + expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); + }); + + it('fails when select_page_then_run targets a missing page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1707, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageId: 'page-9' }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1708, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1708)); + expect(text).toContain('status: failed'); + expect(text).toContain('page not found: page-9'); + }); + it('drags local files onto normal, frame, and shadow dropzones', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); const invoicePath = join(tempDir, 'invoice.pdf'); From f6ef59ef96fdce0571e1b6dd0580a5499615fd52 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 21:14:13 +0800 Subject: [PATCH 37/47] =?UTF-8?q?docs(browser):=20=E5=A2=9E=E8=A1=A5=2011E?= =?UTF-8?q?=20=E8=B7=A8=20page=20orchestration=20=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Browser MCP 文档中的显式跨页 block、run 边界与失败定位说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index a11285ff..8c9afe4f 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -147,6 +147,13 @@ Phase 11D capability details: - artifact files carry `kind`, `version`, `name`, and `payload` - cloud sync, sharing, and complex template management remain out of scope +Phase 11E capability details: + +- `open_page_then_run`, `select_page_then_run`, and `close_page_then_continue` add explicit cross-page orchestration blocks +- cross-page blocks reuse existing page/tab lifecycle control and existing single-page block semantics +- `run` accepts a normal single-page block or a single-layer `sequence` +- nested cross-page blocks, implicit page selection, and page-graph orchestration remain out of scope + Minimal multi-tab workflow examples: ```json From 40c718c584428a9dee61a15e172157935a7ad39f Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Mon, 20 Apr 2026 12:32:14 +0800 Subject: [PATCH 38/47] =?UTF-8?q?fix(browser):=20=E4=BF=AE=E5=A4=8D=20down?= =?UTF-8?q?load=20=E4=BA=8B=E4=BB=B6=E7=9A=84=20pageIndex=20=E8=BF=87?= =?UTF-8?q?=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 browser_wait_for_event 的 download 路径补齐按页面 frame tree 过滤,避免多页面或动态 iframe 下载时误命中或漏命中。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 46 ++++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 178 +++++++++++++++++- 2 files changed, 220 insertions(+), 4 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a26d9ce1..d4339369 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -4174,8 +4174,38 @@ async function waitForPageEvent({ page, timeoutMs, event }) { }); } -async function waitForBrowserDownloadEvent(timeoutMs, event) { - const targets = await fetchJson(`${getHttpUrl()}/json/list`); +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; @@ -4227,6 +4257,16 @@ async function waitForBrowserDownloadEvent(timeoutMs, event) { 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 || '', @@ -4248,7 +4288,7 @@ async function waitForBrowserDownloadEvent(timeoutMs, event) { async function waitForMatchingEvent({ page, timeoutMs, event }) { if (event.kind === 'download') { - return await waitForBrowserDownloadEvent(timeoutMs, event); + return await waitForBrowserDownloadEvent(page, timeoutMs, event); } return await waitForPageEvent({ page, timeoutMs, event }); } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index fdfec7a0..97fa747d 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -84,6 +84,7 @@ type MockDownloadState = { guid?: string; url: string; suggestedFilename: string; + frameId?: string; progress?: MockDownloadProgressState[]; }; @@ -253,12 +254,19 @@ type MockInterceptState = { 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; @@ -781,6 +789,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || []; browserState.canceledDownloadGuids.push(String(message.params?.guid || '')); reply({}); + return; } }); @@ -792,7 +801,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { JSON.stringify({ method: 'Browser.downloadWillBegin', params: { - frameId: `frame-${page.id}`, + frameId: download.frameId || `frame-${page.id}`, guid, url: download.url, suggestedFilename: download.suggestedFilename, @@ -865,6 +874,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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: '' }); @@ -8401,6 +8416,167 @@ describe('ccs-browser MCP server', () => { 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', From acdb93e1a8733c1c9eac803b04785a8113b1ea26 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Wed, 22 Apr 2026 01:03:15 +0800 Subject: [PATCH 39/47] =?UTF-8?q?fix(browser):=20=E5=AF=B9=E9=BD=90=20brow?= =?UTF-8?q?ser=20status=20=E8=BE=93=E5=87=BA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除已不存在的 evalMode 输出,保持 browser command 与当前 browser status 模型一致。 Co-Authored-By: Claude Opus 4.7 --- src/commands/browser-command.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index 6c7b8f42..3633f67e 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -103,7 +103,6 @@ function writeClaudeStatus( writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`); writeLine(` User data dir: ${userDataDirDisplay}`); writeLine(` DevTools port: ${status.devtoolsPort}`); - writeLine(` browser_eval access: ${status.evalMode}`); writeLine(` Managed MCP: ${status.managedMcpServerName}`); writeLine(` Managed path: ${status.managedMcpServerPath}`); if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { @@ -127,7 +126,6 @@ function writeCodexStatus( writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); writeLaunchPolicy(status.policy, writeLine); writeLine(` Managed server: ${status.serverName}`); - writeLine(` browser_eval access: ${status.evalMode}`); writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); if (status.version) { From d8954e9453cb6d9d92f84eeb27db79e839d1e9ce Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Wed, 22 Apr 2026 01:22:58 +0800 Subject: [PATCH 40/47] =?UTF-8?q?fix(browser):=20=E5=AF=B9=E9=BD=90=20phas?= =?UTF-8?q?e11e=20=E4=B8=8E=20upstream=20browser=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 browser 配置相关模块并隔离测试中的宿主环境变量,修复 phase11e 合并 upstream/dev 后的浏览器相关回归。 Co-Authored-By: Claude Opus 4.7 --- src/ccs.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ccs.ts b/src/ccs.ts index 1d8c5ad7..b4fc0287 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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( From 63082d4aed903d83e09dc3ac85f630367fe7a32b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Wed, 22 Apr 2026 01:25:32 +0800 Subject: [PATCH 41/47] fix(image-analysis): clear runtime env on native-read fallback Avoid carrying runtime API credentials and endpoint fields into native Read fallback launches. Co-Authored-By: Claude Opus 4.7 --- src/utils/hooks/get-image-analysis-hook-env.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 1bae3cf8..af8ae37f 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -109,9 +109,12 @@ 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 +123,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', From 4cf826efa24dff5b173881452219e92a4f341381 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Wed, 22 Apr 2026 01:36:11 +0800 Subject: [PATCH 42/47] fix(image-analysis): clear runtime env on native-read fallback Avoid carrying runtime API credentials and endpoint fields into native Read fallback launches. Co-Authored-By: Claude Opus 4.7 --- src/utils/hooks/get-image-analysis-hook-env.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index af8ae37f..d45b218d 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -114,7 +114,9 @@ export function getImageAnalysisHookEnv( : ''; const runtimePath = skipImageAnalysis ? '' : status.runtimePath || ''; const runtimeBaseUrl = - skipImageAnalysis || typeof context !== 'object' ? '' : context.cliproxyBridge?.currentBaseUrl || ''; + skipImageAnalysis || typeof context !== 'object' + ? '' + : context.cliproxyBridge?.currentBaseUrl || ''; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', From 7732b2039ab8501669465f524dc27b3037a7c5b7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 22:34:49 -0400 Subject: [PATCH 43/47] fix(browser): harden Browser MCP sessions and artifacts --- lib/mcp/ccs-browser-server.cjs | 435 ++++++++++++----- .../unit/hooks/ccs-browser-mcp-server.test.ts | 445 +++++++++++++++++- 2 files changed, 772 insertions(+), 108 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index d4339369..1cc92e38 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -149,6 +149,10 @@ 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; @@ -161,9 +165,11 @@ 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 = []; @@ -251,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 []; @@ -1483,7 +1504,7 @@ function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, } const selectedIndex = findPageIndexById(pages, defaultSelectedId); if (selectedIndex === -1) { - if (!allowImplicitFallback) { + if (!allowImplicitFallback && defaultSelectedId) { throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); } const fallbackPage = pages[0]; @@ -2102,6 +2123,59 @@ function getBrowserEvalMode() { 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() : ''; } @@ -2389,13 +2463,28 @@ function buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, fi dataTransfer.items.add(file); } - element.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer })); - element.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); - const accepted = element.dispatchEvent( - new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }) - ); + 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, + }); - return { accepted }; + element.dispatchEvent(dragEnterEvent); + const dragOverCanceled = + element.dispatchEvent(dragOverEvent) === false || dragOverEvent.defaultPrevented; + const dropCanceled = element.dispatchEvent(dropEvent) === false || dropEvent.defaultPrevented; + + return { accepted: dragOverCanceled || dropCanceled }; })()`; } @@ -2450,13 +2539,19 @@ 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 }); + 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) { - return path.join(getArtifactDir(), `${name}.json`); + const safeName = requireArtifactName(name); + return path.join(getArtifactDir(), `${safeName}.json`); } function buildArtifact(kind, name, payload) { @@ -2473,6 +2568,15 @@ 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 { @@ -2562,6 +2666,48 @@ function formatOrchestrationSummary(session) { 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) { @@ -3805,12 +3951,12 @@ async function handleEval(toolArgs) { } const result = response.result; - const value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined; - if (value === undefined && result.type !== 'undefined') { + const normalizedResult = normalizeDevtoolsResultValue(result); + if (!normalizedResult.serializable) { throw new Error('evaluation result is not JSON-serializable'); } - return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`; + return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(normalizedResult.value)}`; } function getButtonMask(button) { @@ -3867,7 +4013,9 @@ async function handlePointerAction(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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.`); } @@ -3942,9 +4090,13 @@ async function handlePointerAction(toolArgs) { if (!pressedButton) { throw new Error('pointer state error'); } - const releaseButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; - await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); - pressedButton = null; + 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) { @@ -3960,7 +4112,9 @@ async function handleDragElement(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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.`); } @@ -4548,7 +4702,9 @@ async function handleSelectPage(toolArgs) { 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); + 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.`); } @@ -4809,7 +4965,9 @@ async function handleSetFileInput(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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'); @@ -4843,7 +5001,9 @@ async function handleDragFiles(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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'); @@ -5026,7 +5186,9 @@ async function handleStartRecording(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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.`); } @@ -5057,13 +5219,24 @@ async function handleStopRecording() { if (!activeRecordingSession) { throw new Error('no active recording'); } - await finalizeRecordingCapture(activeRecordingSession); - activeRecordingSession.status = 'stopped'; - activeRecordingSession.stoppedAt = new Date().toISOString(); - const stopped = activeRecordingSession; + 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 = stopped; - return formatRecordingSummary(stopped); + latestRecordingSession = session; + if (finalizeError) { + throw finalizeError; + } + return formatRecordingSummary(session); } async function handleGetRecording() { @@ -5175,6 +5348,7 @@ function buildReplayToolArgs(step, replayPage) { 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); @@ -5201,9 +5375,39 @@ async function executeReplaySteps(session) { } 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'); @@ -5214,39 +5418,26 @@ async function handleStartReplay(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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 = { - replayId: createReplayId(), - pageId: page.id, - pageIndex, - status: 'running', - stepCount: steps.length, - completedSteps: 0, - currentStepIndex: 0, - failedStepIndex: null, - error: null, - steps, - }; + const session = createReplaySession(page, pageIndex, steps); latestReplaySession = session; activeReplaySession = session; - - try { - await executeReplaySteps(session); - session.status = 'completed'; - } catch (error) { - session.status = 'failed'; - session.failedStepIndex = session.currentStepIndex; - session.error = error instanceof Error ? error.message : String(error); - } - - activeReplaySession = null; - latestReplaySession = 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); } @@ -5259,11 +5450,10 @@ async function handleCancelReplay() { if (!activeReplaySession) { throw new Error('no active replay'); } + activeReplaySession.cancelRequested = true; activeReplaySession.status = 'canceled'; - const canceled = activeReplaySession; - activeReplaySession = null; - latestReplaySession = canceled; - return formatReplaySummary(canceled); + latestReplaySession = activeReplaySession; + return formatReplaySummary(activeReplaySession); } async function executeOrchestrationBlock(session, block) { @@ -5311,9 +5501,15 @@ async function executeOrchestrationBlock(session, block) { } if (block.type === 'run_replay_sequence') { - const replayResultText = await handleStartReplay({ pageId: session.pageId, steps: block.args.steps }); - if (replayResultText.includes('status: failed')) { - throw new Error(replayResultText); + 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; } @@ -5346,7 +5542,9 @@ async function executeOrchestrationBlock(session, block) { 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 { @@ -5363,22 +5561,24 @@ async function executeSequenceSteps(session, block) { if (step.continueOnError === true) { continue; } - throw error; + 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 (session.failedSequenceStepIndex === null || session.failedSequenceStepIndex === undefined) { + if (!wasOrchestrationFailureRecorded(error)) { pushOrchestrationFailure(session, { blockIndex: index, sequenceStepIndex: null, @@ -5387,7 +5587,6 @@ async function executeOrchestrationBlocks(session) { }); } if (block.continueOnError === true) { - session.failedSequenceStepIndex = null; session.completedBlocks = index + 1; continue; } @@ -5396,6 +5595,40 @@ async function executeOrchestrationBlocks(session) { } } +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'); @@ -5406,41 +5639,26 @@ async function handleStartOrchestration(toolArgs) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } - const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + 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 = { - orchestrationId: createOrchestrationId(), - pageId: page.id, - pageIndex, - status: 'running', - blockCount: blocks.length, - completedBlocks: 0, - currentBlockIndex: 0, - failedBlockIndex: null, - failedSequenceStepIndex: null, - error: null, - blocks, - }; + const session = createOrchestrationSession(page, pageIndex, blocks); latestOrchestrationSession = session; activeOrchestrationSession = session; - - try { - await executeOrchestrationBlocks(session); - session.status = 'completed'; - } catch (error) { - session.status = 'failed'; - session.failedBlockIndex = session.currentBlockIndex; - session.errorDetails = formatOrchestrationError(error); - session.error = session.errorDetails.message || (error instanceof Error ? error.message : String(error)); - } - - activeOrchestrationSession = null; - latestOrchestrationSession = 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); } @@ -5449,6 +5667,16 @@ async function handleGetOrchestration() { 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(); @@ -5467,7 +5695,7 @@ function resolveArtifactPath(reference) { if (value.startsWith('artifact:')) { return getArtifactPath(value.slice('artifact:'.length)); } - return value; + return path.resolve(value); } function deleteArtifactByName(name) { @@ -5481,14 +5709,20 @@ function deleteArtifactByName(name) { async function handleExportArtifact(toolArgs) { const kind = requireNonEmptyString(toolArgs.kind, 'kind'); - const name = requireNonEmptyString(toolArgs.name, 'name'); + 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); - fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2), 'utf8'); + 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`; } @@ -5529,29 +5763,18 @@ async function handleImportArtifact(toolArgs) { } async function handleDeleteArtifact(toolArgs) { - const name = requireNonEmptyString(toolArgs.name, 'name'); + const name = requireArtifactName(toolArgs.name); deleteArtifactByName(name); return `name: ${name}\nstatus: deleted`; } -async function handleCancelOrchestration() { - if (!activeOrchestrationSession) { - throw new Error('no active orchestration'); - } - activeOrchestrationSession.status = 'canceled'; - const canceled = activeOrchestrationSession; - activeOrchestrationSession = null; - latestOrchestrationSession = canceled; - return formatOrchestrationSummary(canceled); -} - 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; } @@ -5961,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); diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index 97fa747d..99493228 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; -import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -114,6 +114,7 @@ type MockFileInputPlan = MockFileInputState | MockFileInputState[]; type MockDropzoneState = { accepted?: boolean; + acceptedByCancel?: boolean; requireFiles?: boolean; receivedEventTypes?: string[]; receivedFiles?: Array<{ name: string; size: number; type: string }>; @@ -203,6 +204,7 @@ type MockRecordingPlan = { events?: MockRecordedEvent[]; warnings?: MockRecordingWarning[]; injectionError?: string; + finalizeError?: string; }; type MockFrameState = { @@ -226,6 +228,7 @@ type MockEvalPlan = Record< result?: unknown; error?: string; nonSerializable?: boolean; + unserializableValue?: string; } >; @@ -1121,6 +1124,15 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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', @@ -1174,7 +1186,14 @@ function createMockBrowser(pagesInput: MockPageState[]) { size: file.size, type: file.mimeType, })); - reply({ result: { type: 'object', value: { accepted: target.accepted !== false } } }); + reply({ + result: { + type: 'object', + value: { + accepted: target.acceptedByCancel === true ? true : target.accepted !== false, + }, + }, + }); return; } @@ -1193,6 +1212,20 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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, @@ -5039,6 +5072,9 @@ describe('ccs-browser MCP server', () => { 'throw new Error("boom")': { error: 'boom', }, + Infinity: { + unserializableValue: 'Infinity', + }, window: { nonSerializable: true, }, @@ -5086,6 +5122,15 @@ describe('ccs-browser MCP server', () => { 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' }, @@ -5111,6 +5156,9 @@ describe('ccs-browser MCP server', () => { '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' ); }); @@ -5151,6 +5199,37 @@ describe('ccs-browser MCP server', () => { ); }); + 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( [ @@ -5763,6 +5842,48 @@ describe('ccs-browser MCP server', () => { 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( [ @@ -6069,6 +6190,105 @@ describe('ccs-browser MCP server', () => { 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[] = [ { @@ -6244,6 +6464,112 @@ describe('ccs-browser MCP server', () => { 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[] = [ { @@ -7108,6 +7434,7 @@ describe('ccs-browser MCP server', () => { ]); 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'); @@ -7187,6 +7514,7 @@ describe('ccs-browser MCP server', () => { ]); 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'); @@ -7305,6 +7633,7 @@ describe('ccs-browser MCP server', () => { ]); 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'); @@ -7500,6 +7829,10 @@ describe('ccs-browser MCP server', () => { }); 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( [ { @@ -7518,12 +7851,24 @@ describe('ccs-browser MCP server', () => { { 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 () => { @@ -7798,6 +8143,7 @@ describe('ccs-browser MCP server', () => { currentUrl: 'https://example.com/uploads', dropzones: { '#dropzone': { accepted: true }, + '#cancel-dropzone': { acceptedByCancel: true }, }, frames: [ { @@ -7854,6 +8200,18 @@ describe('ccs-browser MCP server', () => { }, }, }, + { + 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'); @@ -7872,6 +8230,12 @@ describe('ccs-browser MCP server', () => { 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 () => { @@ -8337,6 +8701,21 @@ describe('ccs-browser MCP server', () => { }, }, }, + { + 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'); @@ -8344,6 +8723,68 @@ describe('ccs-browser MCP server', () => { 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 () => { From c24033dc6b301c27a0fe2c75d1e4327222b14313 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 22:35:02 -0400 Subject: [PATCH 44/47] fix(browser): preserve opt-in browser settings and env isolation --- src/cliproxy/executor/env-resolver.ts | 9 +- src/config/unified-config-loader.ts | 41 +++++- src/config/unified-config-types.ts | 7 + src/targets/claude-adapter.ts | 5 +- src/targets/codex-adapter.ts | 3 +- src/utils/browser/browser-settings.ts | 20 ++- src/utils/browser/browser-status.ts | 27 +++- src/utils/shell-executor.ts | 19 ++- src/web-server/middleware/auth-middleware.ts | 4 +- src/web-server/routes/browser-routes.ts | 38 +++++- tests/unit/config/migration-manager.test.ts | 2 + .../targets/codex-runtime-integration.test.ts | 35 ++++- .../default-profile-browser-launch.test.ts | 25 ++++ .../settings-profile-browser-launch.test.ts | 25 ++++ ...ings-profile-image-analysis-launch.test.ts | 24 +++- tests/unit/unified-config.test.ts | 2 + .../unit/utils/browser/browser-status.test.ts | 43 ++++++ tests/unit/web-server/browser-routes.test.ts | 126 +++++++++++++++++- ui/src/lib/api-client.ts | 5 + ui/src/pages/settings/types.ts | 3 +- 20 files changed, 423 insertions(+), 40 deletions(-) diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index bafcea63..07bb96a4 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,8 +383,11 @@ 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( diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a3548fc6..07b79708 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), }, }; } @@ -1131,7 +1152,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 6b294857..3d92af79 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/shell-executor.ts b/src/utils/shell-executor.ts index b6f26ab2..c239b3f7 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,6 +26,22 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +/** + * 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. * @@ -142,10 +158,11 @@ export function execClaude( // with native Claude API routing. Settings-based profiles explicitly inject // their own ANTHROPIC_* values, so they don't need this protection. const profileType = envVars?.CCS_PROFILE_TYPE; - const baseEnv = + const inheritedEnv = profileType === 'account' || profileType === 'default' ? stripAnthropicEnv(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 c42272a2..fdab88c9 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, RequestHandler, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; @@ -84,7 +84,7 @@ export const loginRateLimiter = rateLimit({ /** * Create session middleware configured for CCS dashboard. */ -export function createSessionMiddleware() { +export function createSessionMiddleware(): RequestHandler { const authConfig = getDashboardAuthConfig(); const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000; 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/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/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 474e1429..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,19 +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: '', - 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: '', + 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', () => { @@ -500,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, }, ]); }); @@ -540,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, }); }); @@ -618,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 728a9b2b..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" @@ -266,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 f5af261a..bc3e00cc 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" @@ -253,6 +254,30 @@ 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('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 110d1123..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 `, @@ -261,6 +265,24 @@ exit 0 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', () => { if (process.platform === 'win32') return; 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/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index 128bec04..46482358 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -153,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', @@ -183,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', }, }), }); @@ -199,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', }, }); @@ -213,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', @@ -231,6 +326,7 @@ describe('browser routes', () => { policy: 'manual', userDataDir: '/tmp/ccs-browser-custom', devtoolsPort: 9333, + evalMode: 'readonly', }, }), }); @@ -254,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(); @@ -266,6 +364,7 @@ describe('browser routes', () => { policy: 'manual', user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), devtools_port: 9333, + eval_mode: 'readonly', }, }); }); @@ -336,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 f7219616..38e43778 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -234,17 +234,20 @@ 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; }; } @@ -257,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'; @@ -275,6 +279,7 @@ export interface ClaudeBrowserStatus { export interface CodexBrowserStatus { enabled: boolean; + policy: BrowserToolPolicy; state: 'disabled' | 'enabled' | 'unsupported_build'; title: string; detail: string; diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 3ff14bd4..9784c8dc 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -5,6 +5,7 @@ import type { BrowserEvalMode, + BrowserToolPolicy, BrowserSettingsConfig, BrowserStatusPayload, CliproxyServerConfig, @@ -199,7 +200,7 @@ export interface ThinkingConfig { // === Re-exports from api-client === -export type { BrowserEvalMode, CliproxyServerConfig, RemoteProxyStatus }; +export type { BrowserEvalMode, BrowserToolPolicy, CliproxyServerConfig, RemoteProxyStatus }; export type BrowserConfig = BrowserSettingsConfig; export type BrowserStatus = BrowserStatusPayload; export type BrowserSavePayload = UpdateBrowserSettingsPayload; From e51b53fab959e5f15a58693452fb8b35612108a0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 22:35:12 -0400 Subject: [PATCH 45/47] chore(browser): drop phase design reports --- .../2026-04-18-browser-mcp-phase6-design.md | 272 ----------- .../2026-04-18-browser-mcp-phase7-design.md | 323 ------------- .../2026-04-19-browser-mcp-phase8-design.md | 455 ------------------ 3 files changed, 1050 deletions(-) delete mode 100644 docs/reports/2026-04-18-browser-mcp-phase6-design.md delete mode 100644 docs/reports/2026-04-18-browser-mcp-phase7-design.md delete mode 100644 docs/reports/2026-04-19-browser-mcp-phase8-design.md diff --git a/docs/reports/2026-04-18-browser-mcp-phase6-design.md b/docs/reports/2026-04-18-browser-mcp-phase6-design.md deleted file mode 100644 index 3763c1b5..00000000 --- a/docs/reports/2026-04-18-browser-mcp-phase6-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Browser MCP Phase 6 Design - -Last Updated: 2026-04-18 - -## Goal - -在 `feat/browser-mcp-hover-phase6` worktree 中,为 CCS-managed `ccs-browser` MCP runtime 设计 Browser MCP Phase 6。 -本阶段延续既有 browser MCP 能力蓝图,优先聚焦 network interception / mock responses,并保持 Phase 1-5 一致的最小增量策略。 - -Phase 6 不在当前阶段一次性做完整网络代理层,而是拆成两个连续子阶段: - -- **Phase 6A**:request observation + scoped interception skeleton -- **Phase 6B**:mock responses - -## Scope Summary - -### In Scope for Phase 6A - -- 新增会话级 interception rule 管理能力 -- 新增近期请求摘要查看能力 -- 基于目标页建立 Fetch interception 会话 -- 支持最小匹配集:`urlIncludes`、`method` -- 支持最小动作集:`continue`、`fail` -- 维持 session-local state,不做跨 runtime 重启持久化 - -### Deferred to Phase 6B - -- `fulfill` / mock response action -- 自定义 status / headers / body -- response body 编码与更复杂序列化 -- 更复杂的匹配条件,如资源类型、headers、正则、优先级 -- 浏览器级或跨页共享拦截规则 - -## Architecture - -Phase 6 继续沿用现有 `lib/mcp/ccs-browser-server.cjs` 单 runtime 架构,不额外引入第二套 browser server,也不改写当前页面选择与工具注册模型。 - -现有 runtime 已具备两类基础能力: - -- 页面级 CDP 请求发送:`sendCdpCommand(page, method, params)` -- 事件型 websocket 监听:当前 `browser_wait_for_event` 已能在页级 websocket 上启用 `Page.enable` / `Network.enable` - -Phase 6 在这一基础上增加一层 **session-local interception state**,而不是引入新的全局配置层。 - -整体结构分为三层: - -1. **Tool surface**:新增规则管理和请求查看工具 -2. **Session-local state**:记录规则、近期请求和页级 interception session -3. **Page interception session**:在具体 page target 上维护 Fetch domain 长连接,处理 paused requests - -## Tool Surface - -Phase 6A 先只新增四个工具,形成最小闭环。 - -### `browser_add_intercept_rule` - -为某个页面添加一条会话级拦截规则。 - -建议参数: - -- `pageIndex?: number` -- `pageId?: string` -- `urlIncludes?: string` -- `method?: string` -- `action: "continue" | "fail"` - -行为: - -- 如果省略 `pageIndex/pageId`,按调用当下的 selected page 解析一次目标页 -- 解析结果固化为具体 `pageId` -- 创建 ruleId 并返回 -- 若目标页尚未建立 interception session,则启动该页的 Fetch interception 长连接 - -### `browser_remove_intercept_rule` - -按 `ruleId` 删除一条规则。 - -建议参数: - -- `ruleId: string` - -行为: - -- 删除对应规则 -- 如果某页已无任何规则,回收该页 interception session - -### `browser_list_intercept_rules` - -列出当前 MCP runtime 内存中的有效规则。 - -返回字段最小化,建议包含: - -- `ruleId` -- `pageId` -- `pageTitle` -- `urlIncludes` -- `method` -- `action` - -### `browser_list_requests` - -返回近期观测到的请求摘要。 - -建议参数: - -- `pageIndex?: number` -- `pageId?: string` -- `limit?: number` - -返回字段最小化,建议包含: - -- `requestId` -- `pageId` -- `url` -- `method` -- `resourceType` -- `matchedRuleId` -- `action` - -## State Model - -Phase 6A 在 runtime 内维护三块 session-local 状态: - -### `interceptRules` - -存储当前有效规则列表。每条规则至少包含: - -- `ruleId` -- `pageId` -- `pageTitleSnapshot` -- `urlIncludes` -- `method` -- `action` -- `createdAt` - -关键约束: - -- 规则绑定到**创建时解析出的具体 pageId** -- 规则不会在后续切换 selected page 时自动漂移 -- 规则不持久化到磁盘 - -### `recentRequests` - -存储近期请求摘要,供 `browser_list_requests` 使用。 - -关键约束: - -- 固定上限,建议 100 条 -- 超过上限时丢弃最旧记录 -- 只保留摘要,不保留 body - -### `interceptSessionsByPageId` - -存储每个被拦截页面对应的长连接会话。 - -每个 session 至少需要管理: - -- `pageId` -- `webSocketDebuggerUrl` -- websocket 实例 -- 当前是否已启用 `Fetch.enable` -- 关联规则数量 -- 最近错误状态(如果需要) - -## Data Flow - -### Add Rule Flow - -1. `browser_add_intercept_rule` 解析目标页 -2. 校验参数与最小匹配条件 -3. 生成 `ruleId` -4. 将规则写入 `interceptRules` -5. 为目标页创建或复用 interception session -6. 在该页 websocket 上启用 `Fetch.enable` -7. 返回 rule 摘要 - -### Request Interception Flow - -1. 页级 interception session 收到 paused request 事件 -2. 从该页规则中按创建顺序依次匹配 -3. 第一条命中规则决定动作 -4. 执行动作: - - `continue` -> 放行请求 - - `fail` -> 以固定错误原因中止请求 -5. 写入 `recentRequests` -6. 若无命中规则,则默认 `continue` - -### Remove Rule Flow - -1. `browser_remove_intercept_rule` 按 `ruleId` 找到规则 -2. 从 `interceptRules` 删除 -3. 若规则所属页已无剩余规则,则关闭该页 interception session -4. 返回删除结果 - -### List Requests Flow - -1. `browser_list_requests` 按可选 page filter 过滤 -2. 按时间倒序返回近期摘要 -3. 应用 `limit` - -## Error Handling and Boundary Rules - -### Parameter Rules - -`browser_add_intercept_rule`: - -- `pageIndex` 和 `pageId` 不能同时传 -- `action` 只能是 `continue` 或 `fail` -- 至少要有一个匹配条件:`urlIncludes` 或 `method` -- 目标页不存在、已关闭或无 websocket target 时直接报错 - -`browser_remove_intercept_rule`: - -- `ruleId` 不存在时报错 - -`browser_list_requests`: - -- `pageIndex` 和 `pageId` 不能同时传 -- `limit` 越界时回退到安全默认值 - -### Lifecycle Rules - -- 所有 interception 规则都是 session-local in-memory state -- 目标页关闭后,该页规则自动失效并从规则列表中清除 -- `browser_list_intercept_rules` 只返回当前仍有效的规则 -- runtime 重启后,所有规则和请求摘要都会丢失 - -### Concurrency Rules - -- 规则管理工具继续沿用当前 runtime 的串行消息语义,避免状态竞态 -- 真正的请求拦截事件在页级长连接中异步处理 -- 页级拦截事件只允许修改自身 pageId 对应的 interception session 状态,以及共享的 `recentRequests` - -## Interaction with Existing Phase 5 State - -Phase 6 必须与现有 selected page 语义兼容: - -- 添加规则时若省略目标页参数,只在创建当下读取 selected page 一次 -- 规则创建完成后绑定到具体 pageId,不再受后续切页影响 -- `browser_select_page`、`browser_open_page`、`browser_close_page` 不直接重写既有规则的 page binding -- 如果 `browser_close_page` 关闭了被规则绑定的页面,相关规则应自动清理 - -## Testing Strategy - -Phase 6A 测试应继续扩展 `tests/unit/hooks/ccs-browser-mcp-server.test.ts` 里的 deterministic harness。 - -最小测试集建议覆盖: - -1. 工具列表与 schema 暴露正确 -2. 添加规则后能在规则列表中看到 -3. 删除规则后规则消失 -4. 两条规则按创建顺序匹配,先命中者生效 -5. 省略 `pageIndex/pageId` 时按当前 selected page 绑定 -6. 切换 selected page 后,旧规则仍绑定原 pageId -7. 关闭绑定页面后规则自动清理 -8. `browser_list_requests` 只返回摘要并 obey `limit` -9. 无命中规则时默认 continue -10. `fail` 动作能生成稳定且可断言的结果 - -## Why This Split - -将 Phase 6 拆成 6A / 6B 的原因是: - -- 先稳定规则管理、页级会话和 paused request 生命周期 -- 再叠加 mock fulfill response,避免一开始把匹配、状态管理、headers/body 序列化全耦合到一起 -- 与 Phase 1-5 的最小闭环推进方式保持一致 - -6A 解决的是“能稳定地拦住并控制请求”;6B 再解决“能把响应内容完全替换掉”。 - -## Out of Scope in This Design Pass - -本轮设计确认只覆盖 Phase 6 的拆分方式与 6A 的最小闭环,不直接进入 6B 的详细实现步骤,也不在这里展开 implementation plan。 diff --git a/docs/reports/2026-04-18-browser-mcp-phase7-design.md b/docs/reports/2026-04-18-browser-mcp-phase7-design.md deleted file mode 100644 index 73ab8d82..00000000 --- a/docs/reports/2026-04-18-browser-mcp-phase7-design.md +++ /dev/null @@ -1,323 +0,0 @@ -# Browser MCP Phase 7 Design - -Last Updated: 2026-04-18 - -## Goal - -在新的 `feat/browser-mcp-hover-phase7` worktree 中,为 CCS-managed `ccs-browser` MCP runtime 设计 Browser MCP Phase 7。 -本阶段延续 Phase 6 的 network interception 路线,优先聚焦 **richer request matching**,让现有拦截规则可以按更细的请求特征命中,同时保持既有最小增量策略。 - -## Scope Summary - -### In Scope - -- 在现有 `browser_add_intercept_rule` 基础上扩展更丰富的匹配条件 -- 新增最小匹配维度: - - `resourceType` - - `urlPattern` 或 `urlRegex`(二选一) - - `headerMatchers` - - `priority` -- 将规则匹配顺序从“创建顺序”扩展为: - 1. `priority` 从高到低 - 2. 同优先级按创建顺序 -- 扩展 `browser_list_intercept_rules` 输出,展示新增匹配摘要 -- 保持 `browser_list_requests` 为请求摘要视图,不输出 request/response body -- 保持 selected page 解析语义、规则绑定具体 `pageId`、session-local runtime state、页级 websocket interception session 与 `messageQueue` 串行语义 - -### Explicitly Out of Scope - -- 跨页共享规则或浏览器级全局规则 -- 下载接受/拒绝控制与下载文件管理 -- 请求体匹配 -- 响应阶段匹配 -- 更复杂布尔逻辑(如任意/全部组、嵌套条件、否定条件) -- 完整网络代理层 - -## Why Phase 7 Follows Phase 6 - -Phase 6 已建立: - -- page-bound 的 interception rule 管理面 -- session-local `interceptRules` / `recentRequests` / `interceptSessionsByPageId` -- `continue` / `fail` / `fulfill` 三种最小动作集 - -历史设计里也已明确把以下能力延后到后续阶段: - -- 更复杂的匹配条件,如 `resourceType`、`headerMatchers`、正则、优先级 -- 跨页共享规则 -- 下载接受控制 - -因此,Phase 7 继续沿 network interception 主线推进 richer matching,是与现有实现最连续、收益最大的下一阶段;它能显著提升规则可用性,同时不会把作用域和下载控制这两类新问题混进同一期。 - -## Architecture - -Phase 7 继续沿用 `lib/mcp/ccs-browser-server.cjs` 的单 runtime 架构,不引入新的 browser runtime,也不改写 Phase 5 的 selected page 模型和 Phase 6 的 interception session 生命周期。 - -本阶段仍沿用三层结构: - -1. **Tool surface**:复用现有四个 interception 相关工具,只扩展参数与输出摘要 -2. **Session-local state**:继续复用 `interceptRules`、`recentRequests`、`interceptSessionsByPageId` -3. **Page interception session**:继续由具体 page target 的 websocket 消费 paused requests,并根据 richer matching 结果执行动作 - -关键设计约束: - -- 规则仍在创建时解析并固化为具体 `pageId` -- 规则不会在后续 selected page 切换时自动漂移 -- 新增 richer matching 不改变现有 action 执行路径,只改变“哪条规则命中” -- `messageQueue` 继续只负责工具入口串行语义;页级 websocket 仍异步接收 paused request 事件 - -## Tool Surface - -Phase 7 不新增新工具,继续复用: - -- `browser_add_intercept_rule` -- `browser_remove_intercept_rule` -- `browser_list_intercept_rules` -- `browser_list_requests` - -### `browser_add_intercept_rule` - -在 Phase 6 参数基础上,扩展最小 richer matching 参数: - -- `pageIndex?: number` -- `pageId?: string` -- `urlIncludes?: string` -- `method?: string` -- `resourceType?: string` -- `urlPattern?: string` -- `urlRegex?: string` -- `headerMatchers?: Array<{ name: string; valueIncludes?: string; valueRegex?: string }>` -- `priority?: number` -- `action: "continue" | "fail" | "fulfill"` -- `statusCode?`, `contentType?`, `responseHeaders?`, `body?`(仅 `action: "fulfill"` 时沿用 Phase 6B 现有语义;避免与请求匹配用的 `headerMatchers` 混淆) - -约束: - -- `pageIndex` 与 `pageId` 不能同时传 -- `urlPattern` 与 `urlRegex` 不能同时传 -- `priority` 默认 `0` -- `priority` 必须是整数 -- header matcher 的 `name` 必填 -- 每个 header matcher 至少需要一个值条件:`valueIncludes` 或 `valueRegex` -- Phase 7 仍要求至少有一个请求匹配条件,避免创建“无条件全匹配”规则 - -### `browser_remove_intercept_rule` - -保持不变,仅按 `ruleId` 删除对应规则。 - -### `browser_list_intercept_rules` - -在现有输出基础上,新增简洁摘要字段: - -- `priority` -- `resourceType` -- `urlPattern` 或 `urlRegex` -- `headerMatchers` 条件数量或简写摘要 - -要求: - -- 仍保持文本摘要可读性 -- 不直接把长正则或大量 header 条件原样完整展开到难以阅读 - -### `browser_list_requests` - -保持“近期请求摘要”定位,不展示 request/response body。 - -可选补充字段: - -- `matchedRuleId` -- `action` -- `statusCode`(仅 fulfill) -- 必要时增加一个轻量命中说明,如 `matchedBy`,只表示命中依赖了哪类条件,不重复整条规则 - -## State Model - -Phase 7 仍复用 Phase 6 的三块 session-local 状态。 - -### `interceptRules` - -每条规则在 Phase 6 基础上新增: - -- `resourceType` -- `urlPattern` -- `urlRegex` -- `headerMatchers` -- `priority` - -关键约束: - -- 规则绑定到创建时解析出的具体 `pageId` -- 规则仍不持久化到磁盘 -- `priority` 仅影响匹配顺序,不影响工具展示或生命周期清理逻辑 - -### `recentRequests` - -仍然只存储近期请求摘要,继续不保留 body。 - -如增加 `matchedBy`,也只记录轻量摘要,而不是完整匹配细节。 - -### `interceptSessionsByPageId` - -不新增新的 session 级状态表。 - -Phase 7 只扩匹配逻辑,不改 websocket 生命周期模型: - -- 规则创建时按需建立 session -- 页面关闭或无规则时回收 session -- websocket 断开时继续进行现有清理与错误恢复语义 - -## Matching Model - -### Matching Order - -对某个 paused request,只在其所属 `pageId` 的规则集合内匹配。 - -匹配顺序: - -1. `priority` 从高到低 -2. 同优先级按创建顺序 -3. 第一条完整命中的规则生效 - -这意味着 Phase 7 解决了 Phase 6 “只能按创建顺序命中”的限制,同时避免引入更复杂的规则编排系统。 - -### Rule Predicate Evaluation - -每条规则依次检查: - -1. `method` -2. `urlIncludes` -3. `urlPattern` 或 `urlRegex` -4. `resourceType` -5. `headerMatchers` - -所有已提供条件都必须命中,规则才算匹配成功。 - -### Header Matching - -Phase 7 的 header matching 采用最小规则: - -- header 名称按大小写不敏感匹配 -- 每个 matcher 至少要求一个值条件: - - `valueIncludes` - - `valueRegex` -- 多个 header matcher 之间采用“全部满足”语义 - -Phase 7 不支持: - -- any/all 模式切换 -- header 多值数组高级语义 -- 负向匹配 -- body/JSON 级内容匹配 - -## Data Flow - -### Add Rule Flow - -1. `browser_add_intercept_rule` 解析目标页 -2. 校验 richer matching 参数 -3. 生成 `ruleId` -4. 将规则写入 `interceptRules` -5. 为目标页创建或复用 interception session -6. 返回包含新增匹配摘要的 rule 文本 - -### Request Match Flow - -1. 页级 interception session 收到 paused request 事件 -2. 取出该页全部有效规则 -3. 按 `priority DESC + createdAt ASC` 排序 -4. 逐条检查 method / URL / resourceType / `headerMatchers` 条件 -5. 第一条命中规则决定 action: - - `continue` - - `fail` - - `fulfill` -6. 将结果写入 `recentRequests` -7. 若无任何规则命中,则默认 `continue` - -### Remove Rule Flow - -保持 Phase 6 行为: - -1. `browser_remove_intercept_rule` 按 `ruleId` 找到规则 -2. 从 `interceptRules` 删除 -3. 若该页已无剩余规则,则关闭该页 interception session -4. 返回删除结果 - -## Error Handling and Boundary Rules - -### Parameter Rules - -`browser_add_intercept_rule`: - -- `pageIndex` 和 `pageId` 不能同时传 -- `urlPattern` 和 `urlRegex` 不能同时传 -- `priority` 必须是整数 -- `headerMatchers` 必须是数组 -- 每个 header matcher 必须有 `name` -- 每个 header matcher 必须至少有 `valueIncludes` 或 `valueRegex` -- 目标页不存在、已关闭或无 websocket target 时直接报错 - -### Matching Rules - -- 不支持空 header matcher -- 不支持空正则 -- 不支持通过 richer matching 创建跨页规则 -- 同一请求只应用第一条命中的规则 - -### Lifecycle Rules - -- 页面关闭时,绑定该 `pageId` 的规则与近期请求摘要继续按 Phase 6 规则清理 -- websocket 关闭时不保留悬挂 session -- selected page 变化不影响已创建规则的 `pageId` 绑定 - -## Testing Strategy - -Phase 7 继续沿用既有 TDD 路线,在 `tests/unit/hooks/ccs-browser-mcp-server.test.ts` 上扩展 deterministic harness 与行为断言。 - -建议拆分为: - -1. **Schema / 参数失败测试** - - `urlPattern` 与 `urlRegex` 同传时报错 - - `priority` 非整数时报错 - - `headerMatchers` 不是数组时报错 - - header matcher 缺 `name` 报错 - - header matcher 既无 `valueIncludes` 也无 `valueRegex` 报错 - -2. **主路径测试** - - `resourceType` 命中 - - `urlPattern` 命中 - - `urlRegex` 命中 - - `headerMatchers.valueIncludes` 命中 - - `headerMatchers.valueRegex` 命中 - -3. **优先级与回归测试** - - 更高 `priority` 的规则覆盖较低优先级规则 - - 同优先级仍按创建顺序命中 - - selected page 切换后规则仍绑定原 `pageId` - - 页面关闭后 richer matching 规则与请求摘要一起清理 - -4. **仓库验证顺序** - - focused test - - `bun run build:server` - - `bun run format` - - `bun run validate` - - `bun run validate:ci-parity` - -## Documentation Impact - -如果 Phase 7 落地,`docs/browser-automation.md` 需要同步更新: - -- Phase 7 capability details -- richer matching 示例 -- 当前仍未支持的范围:跨页规则、下载控制、请求体匹配等 - -## Phase 7 Deliverable Boundary - -Phase 7 的交付结果应当是: - -- 用户可以继续用现有四个 interception 工具 -- 用户可以创建更精细的 page-bound 规则 -- 用户可以用 `priority` 控制命中顺序 -- 用户仍在同一 session-local interception 模型里工作 -- 代码不因为这期而引入新的全局作用域、下载控制或代理层复杂度 - -这让 Browser MCP 的 network interception 从“最小可用”进入“更稳定可控”,但仍然保持单期可交付范围。 diff --git a/docs/reports/2026-04-19-browser-mcp-phase8-design.md b/docs/reports/2026-04-19-browser-mcp-phase8-design.md deleted file mode 100644 index b2993bf6..00000000 --- a/docs/reports/2026-04-19-browser-mcp-phase8-design.md +++ /dev/null @@ -1,455 +0,0 @@ -# Browser MCP Phase 8 Design - -Last Updated: 2026-04-19 - -## Goal - -在当前已完成并提交的 Browser MCP Phase 7 基线之上,为 CCS-managed `ccs-browser` MCP runtime 设计 Browser MCP Phase 8。 - -本阶段延续历史 roadmap 中 Phase 7 预留的 file-transfer / lifecycle completion 方向,但按当前官方 CDP 能力边界做最小修正: - -- **下载控制**:采用 session-local、browser-scoped 的下载策略与下载结果摘要 -- **最小上传**:采用 page-scoped 的 file input 设置能力 - -Phase 8 的目标是补齐“文件流转”这一类真实浏览器工作流缺口,让 browser MCP 不再只有页面交互与网络拦截,还能覆盖最常见的下载与表单文件上传场景。 - -## Why Phase 8 Follows Phase 7 - -历史 spec 的主路线是: - -- Phase 5:page/tab control -- Phase 6:network interception + mock responses -- Phase 7:file-transfer and lifecycle completion -- Phase 8+:recording / replay / higher-level orchestration - -后来实际交付里,Phase 6 被拆成 6A / 6B,Phase 7 又继续完成了 richer request matching。因此当前 browser MCP 已经把 interception 线打磨到了一个较完整的最小闭环: - -- page-bound interception rules -- session-local state -- `continue` / `fail` / `fulfill` -- richer request matching -- recent request summaries - -下一阶段如果继续深挖 interception,会开始落入 request body matching、更复杂 boolean matcher groups、cross-page shared rules 这类更重的规则编排问题;而历史 roadmap 里原本预留的 file transfer 缺口仍未补上。 - -因此,Phase 8 应该回到原 roadmap,优先补: - -- **download handling** -- **minimal upload handling** - -这样既尊重了最初路线,也能让 browser MCP 覆盖更多真实 end-to-end workflow。 - -## Scope Summary - -### In Scope - -1. **下载控制** - - 为当前 attach session 设置下载策略:接受或拒绝下载 - - 支持 session-local 下载目录配置 - - 记录近期下载摘要 - - 支持取消进行中的下载 - - 继续兼容现有 `browser_wait_for_event(kind=download)` 事件等待能力 - -2. **最小上传** - - 为 `` 设置一个或多个本地文件 - - 支持当前已有 selector 作用域能力:`nth`、`frameSelector`、`pierceShadow` - - 保持 selected page 解析语义 - -3. **状态可见性** - - 通过新的下载列表工具查看近期下载摘要与状态 - - 返回清晰的 upload / download 结果摘要 - -### Explicitly Out of Scope - -- 原生系统文件选择器接管 -- drag-and-drop 上传 -- 下载文件内容读取、解析或回传 -- 上传后自动点击 submit 或自动编排后续页面流程 -- 跨 runtime 持久化下载策略或下载记录 -- 基于 interception 的下载内容改写 -- 录制 / 回放 / 宏编排 -- request body matching -- 更复杂 boolean matcher groups -- 跨页共享 interception rules - -## CDP Constraint and Scope Correction - -Phase 8 的关键设计修正来自当前官方 CDP 能力边界: - -### Uploads - -上传侧可基于 `DOM.setFileInputFiles` 实现,因此天然适合继续沿用当前 browser MCP 的 page-scoped / selector-scoped 工具模型。 - -### Downloads - -下载侧主要依赖 Browser domain: - -- `Browser.setDownloadBehavior` -- `Browser.cancelDownload` -- `Browser.downloadWillBegin` -- `Browser.downloadProgress` - -这些接口更接近 **browser-scoped / browser-context-scoped** 能力,而不是某个 page target 独占的 page-bound 能力。因此 Phase 8 不应伪装成“每页独立下载开关”,而应明确采用: - -- **下载控制:session-local, browser-scoped** -- **上传控制:page-scoped** - -这是一个有意的混合作用域设计,但它与 CDP 现实能力一致,也避免了 misleading 的 page-local 承诺。 - -## Architecture - -Phase 8 继续沿用 `lib/mcp/ccs-browser-server.cjs` 的单 runtime 架构,不引入新的 browser runtime,也不重写 Phase 5 selected page 模型或 Phase 6-7 的 interception 架构。 - -本阶段分成两条并行但边界清晰的能力线: - -1. **Download control line** - - browser-scoped CDP Browser domain 控制 - - session-local 下载策略与下载摘要 -2. **File input upload line** - - page-scoped selector 解析 - - DOM file input 赋值 - -### Design Principles - -- 下载控制尊重 CDP Browser domain 的真实作用域,不伪造成 page-bound -- 上传继续复用已有 selector / frame / shadow 解析模型,不新建第二套选择器系统 -- 继续保持 session-local state,不持久化到磁盘 -- 继续保持最小增量策略,不把 download/upload 扩展成完整文件管理系统 -- 不为了 Phase 8 重构现有 wait / event / interception 基础设施 - -## Tool Surface - -Phase 8 推荐新增三个工具。 - -### 1. `browser_set_download_behavior` - -为当前 attach session 设置下载策略。 - -建议参数: - -- `behavior: "accept" | "deny"` -- `downloadPath?: string` -- `eventsEnabled?: boolean` - -行为: - -- `behavior=accept` 时允许下载 -- `behavior=deny` 时拒绝下载 -- `downloadPath` 仅在 accept 时有效;如省略,则 runtime 选择 session-local 默认下载目录 -- `eventsEnabled` 默认开启,保证下载事件和结果摘要可见 -- 作用域为当前 attach session / browser context,而不是单个页面 - -返回字段建议最小包含: - -- `scope: browser` -- `behavior` -- `downloadPath` -- `eventsEnabled` -- `status` - -### 2. `browser_list_downloads` - -列出当前 MCP runtime 记录的近期下载摘要。 - -建议参数: - -- `limit?: number` -- `pageId?: string` - -行为: - -- 默认返回最近下载摘要 -- 可按触发下载时记录的 `pageId` 过滤 -- 不读取下载文件内容 -- 只返回摘要和状态 - -返回字段建议最小包含: - -- `downloadId` -- `guid` -- `pageId` -- `url` -- `suggestedFilename` -- `status` -- `savedPath` -- `startedAt` -- `finishedAt` - -### 3. `browser_cancel_download` - -取消进行中的下载。 - -建议参数: - -- `downloadId?: string` -- `guid?: string` - -行为: - -- 按 runtime 记录的 `downloadId` 或底层 `guid` 定位目标 -- 调用 Browser domain cancel 能力 -- 更新 `recentDownloads` 中对应项状态 - -约束: - -- `downloadId` 与 `guid` 至少提供一个 -- 两者同时提供时必须指向同一条下载记录,否则报错 - -### 4. `browser_set_file_input` - -为 `` 直接设置本地文件。 - -建议参数: - -- `selector: string` -- `files: string[]` -- `pageIndex?: number` -- `pageId?: string` -- `nth?: number` -- `frameSelector?: string` -- `pierceShadow?: boolean` - -行为: - -- 解析目标页 -- 解析 selector 作用域 -- 定位目标节点 -- 要求目标是 `` -- 将本地文件路径集合设置到该 input -- 返回设置结果摘要 - -返回字段建议最小包含: - -- `pageId` -- `pageIndex` -- `selector` -- `fileCount` -- `status` - -## State Model - -Phase 8 继续使用 session-local in-memory state。 - -### `downloadBehaviorState` - -存储当前 attach session 的下载行为配置。 - -建议字段: - -- `behavior` -- `downloadPath` -- `eventsEnabled` -- `updatedAt` - -关键约束: - -- browser-scoped -- 不绑定 pageId -- runtime 重启后丢失 - -### `recentDownloads` - -记录近期下载摘要。 - -建议字段: - -- `downloadId` -- `guid` -- `pageId` -- `url` -- `suggestedFilename` -- `status: "started" | "inProgress" | "completed" | "canceled" | "denied" | "failed"` -- `savedPath` -- `receivedBytes` -- `totalBytes` -- `startedAt` -- `finishedAt` - -关键约束: - -- 固定上限,建议 100 条 -- 只保留摘要,不读文件内容 -- 通过 `guid` 关联 Browser domain 下载事件 - -### Existing selected page state - -上传工具继续复用现有 selected page 模型: - -- 显式 `pageIndex` 优先 -- 否则走 selected page -- stale selected page 时回退到第一可用页面 - -## Data Flow - -### Download behavior update flow - -1. `browser_set_download_behavior` 校验参数 -2. 若 `behavior=accept` 且未提供 `downloadPath`,计算 session-local 默认下载目录 -3. 调用 `Browser.setDownloadBehavior` -4. 更新 `downloadBehaviorState` -5. 返回当前配置摘要 - -### Download observation flow - -1. runtime 接收 `Browser.downloadWillBegin` -2. 生成 `downloadId` -3. 写入 `recentDownloads`,状态为 `started` -4. 记录触发时关联的 `pageId`(若能确定) -5. 若后续收到 `Browser.downloadProgress`: - - 更新 `receivedBytes` / `totalBytes` - - 当 state=`completed` 时写入 `finishedAt` 和可用 `savedPath` - - 当 state=`canceled` 时更新状态 - -### Download cancel flow - -1. `browser_cancel_download` 解析目标记录 -2. 调用 `Browser.cancelDownload` -3. 更新 `recentDownloads` 为 `canceled` -4. 返回取消结果摘要 - -### File input upload flow - -1. `browser_set_file_input` 解析目标页 -2. 解析 `selector` / `nth` / `frameSelector` / `pierceShadow` -3. 定位 DOM 节点 -4. 校验目标是 `` -5. 校验所有本地文件路径存在且为普通文件 -6. 调用 `DOM.setFileInputFiles` -7. 返回设置结果摘要 - -## Error Handling and Boundary Rules - -### `browser_set_download_behavior` - -- `behavior` 只能是 `accept` 或 `deny` -- `behavior=deny` 时不接受 `downloadPath` -- `downloadPath` 不可写时报错 -- Browser domain 下载能力不可用时报错 - -### `browser_list_downloads` - -- `limit` 越界时回退到安全默认值 -- `pageId` 不存在时返回空列表,不报错 - -### `browser_cancel_download` - -- `downloadId` 与 `guid` 至少传一个 -- 指定下载不存在时报错 -- 下载已完成时取消应报明确错误或返回 no-op 状态,但语义必须固定 - -### `browser_set_file_input` - -- `pageIndex` 与 `pageId` 不能同时传 -- `selector` 不能为空 -- `files` 必须是非空数组 -- 文件不存在时报错 -- 文件路径不是普通文件时报错 -- selector 无匹配时报错 -- 多匹配但未提供 `nth` 时应保持与当前 selector 工具一致的目标解析语义 -- 命中的元素不是 `` 时报错 -- 跨域 iframe 不支持 -- closed shadow root 不支持 - -## Interaction with Existing Capabilities - -### With `browser_wait_for_event` - -Phase 8 不替换 `browser_wait_for_event(kind=download)`;它继续负责: - -- 等待下载开始事件 -- 作为轻量事件观察能力存在 - -Phase 8 新增的下载工具负责: - -- 设置下载策略 -- 查看下载摘要 -- 取消下载 - -### With selected page semantics - -- 下载行为是 browser-scoped,不依赖 selected page -- 上传行为继续遵循 selected page 解析语义 -- 这两者的作用域不同是有意设计,不属于不一致 bug - -### With interception state - -- 下载工具不复用 interception rule 管理面 -- 下载记录与 request summaries 是并行状态,不相互嵌套 -- 不把 download handling 强行建模成 interception action - -## Testing Strategy - -Phase 8 继续扩展: - -- `tests/unit/hooks/ccs-browser-mcp-server.test.ts` - -并在 deterministic harness 上增加最小 download / upload 模拟能力。 - -### Required download coverage - -1. 工具列表与 schema 暴露正确 -2. `browser_set_download_behavior` accept 成功 -3. `browser_set_download_behavior` deny 成功 -4. `behavior=deny` + `downloadPath` 报错 -5. 下载开始后 `browser_list_downloads` 能看到 started 摘要 -6. 下载完成后能看到 completed 状态与文件路径 -7. 下载取消后能看到 canceled 状态 -8. `browser_wait_for_event(kind=download)` 继续兼容 -9. `limit` 和 `pageId` 过滤生效 - -### Required upload coverage - -1. 单文件上传成功 -2. 多文件上传成功 -3. selector 无匹配失败 -4. 命中的不是 file input 失败 -5. 文件不存在失败 -6. `frameSelector` 作用域成功 -7. `pierceShadow` 作用域成功 -8. 省略 `pageIndex/pageId` 时命中当前 selected page -9. 显式 `pageIndex` 仍覆盖 selected page - -### Repository validation order - -继续遵循该区域已稳定使用的验证顺序: - -1. focused browser MCP unit tests -2. `bun run build:server` -3. `bun run format` -4. `bun run validate` -5. `bun run validate:ci-parity` - -## Documentation Impact - -如果 Phase 8 实现落地,至少需要同步: - -- `docs/browser-automation.md` - -需要新增: - -- Phase 8 capability details -- 下载控制与上传示例 -- browser-scoped downloads / page-scoped uploads 的作用域说明 -- 新的 out-of-scope 边界说明 - -## Worktree Requirement - -后续实现不应继续直接在当前 Phase 7 worktree 上混写。 - -推荐隔离方式: - -1. 保留 `feat/browser-mcp-hover-phase7` 作为已完成 checkpoint -2. 基于其已验证提交创建新的 Phase 8 worktree -3. 在新 worktree 中完成 Phase 8 plan 与实现 - -## Success Criteria - -Phase 8 成功的标志是: - -1. 用户可以显式控制当前 attach session 的下载接受/拒绝行为 -2. 用户可以查看近期下载摘要并取消进行中的下载 -3. 用户可以向 `` 设置本地文件 -4. 下载与上传能力都遵循清晰、真实的作用域语义 -5. 本期不引入 drag-and-drop、原生文件选择器接管、内容读取或 orchestration 复杂度 - -这使 Browser MCP 在当前阶段从“页面交互 + 网络拦截”扩展到“最小文件流转闭环”,为后续更高阶的录制、回放和 workflow orchestration 打下更完整的基础。 From 50fa27f0a55b8ff8eeccbe5b19c4d04aa8724605 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 23:07:50 -0400 Subject: [PATCH 46/47] test(browser): split Browser MCP hook coverage by domain --- src/ccs.ts | 8 +- src/cliproxy/executor/env-resolver.ts | 6 +- .../env-resolver-codex-fallback.test.ts | 36 + .../browser-mcp-advanced-interactions.test.ts | 1288 +++ .../browser-mcp-downloads-and-files.test.ts | 413 + .../browser-mcp-navigation-and-query.test.ts | 1696 +++ ...er-mcp-orchestration-and-artifacts.test.ts | 1765 +++ .../browser-mcp-recording-and-replay.test.ts | 626 ++ ...browser-mcp-session-and-intercepts.test.ts | 1817 ++++ tests/unit/hooks/browser-mcp-test-harness.ts | 1861 ++++ .../unit/hooks/ccs-browser-mcp-server.test.ts | 9414 ----------------- .../settings-profile-browser-launch.test.ts | 35 + 12 files changed, 9545 insertions(+), 9420 deletions(-) create mode 100644 tests/unit/hooks/browser-mcp-advanced-interactions.test.ts create mode 100644 tests/unit/hooks/browser-mcp-downloads-and-files.test.ts create mode 100644 tests/unit/hooks/browser-mcp-navigation-and-query.test.ts create mode 100644 tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts create mode 100644 tests/unit/hooks/browser-mcp-recording-and-replay.test.ts create mode 100644 tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts create mode 100644 tests/unit/hooks/browser-mcp-test-harness.ts delete mode 100644 tests/unit/hooks/ccs-browser-mcp-server.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index 0a5ca3df..23389316 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors'; import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions -import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor'; +import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; @@ -1367,8 +1367,9 @@ async function main(): Promise { // env free of ANTHROPIC routing/auth while preserving non-routing profile // env so nested Team/subagent sessions can still inherit model intent and // other profile-scoped runtime flags. + const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv }); const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { - ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...stripAnthropicRoutingEnv(settingsRuntimeEnv), ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, @@ -1379,8 +1380,7 @@ async function main(): Promise { // Non-Claude targets still need effective credentials injected directly. const envVars: NodeJS.ProcessEnv = { - ...globalEnv, - ...settingsEnv, + ...settingsRuntimeEnv, ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 07bb96a4..b5872947 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -390,8 +390,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record ) as Record; - const effectiveEnvVarsFiltered = Object.fromEntries( - Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + const effectiveEnvVarsFiltered = stripBrowserEnv( + Object.fromEntries( + Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + ) as Record ) as Record; const mergedEnv = { diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 39ee4e6d..2d8dee54 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -215,6 +215,42 @@ describe('resolveCliproxyImageAnalysisEnv', () => { expect(result.warning).toContain('native Read'); }); + it('strips browser env injected through settings before building Claude runtime env', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-env-strip-')); + tempDirs.push(tempDir); + + const settingsPath = path.join(tempDir, 'browser-env.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'gemini-2.5-pro', + CCS_BROWSER_USER_DATA_DIR: '/tmp/embedded-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/embedded-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env', + }, + }, + null, + 2 + ) + '\n' + ); + + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + verbose: false, + }); + + expect(env.CCS_BROWSER_USER_DATA_DIR).toBeUndefined(); + expect(env.CCS_BROWSER_PROFILE_DIR).toBeUndefined(); + expect(env.CCS_BROWSER_DEVTOOLS_WS_URL).toBeUndefined(); + }); + it('keeps cliproxy image analysis active when the execution target is reachable', async () => { const result = await resolveCliproxyImageAnalysisEnv( { diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts new file mode 100644 index 00000000..bb6d42a4 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -0,0 +1,1288 @@ +import { describe, expect, it } from 'bun:test'; +import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - advanced interactions', () => { + it('drags local files onto normal, frame, and shadow dropzones', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); + const invoicePath = join(tempDir, 'invoice.pdf'); + const receiptPath = join(tempDir, 'receipt.png'); + writeFileSync(invoicePath, 'invoice'); + writeFileSync(receiptPath, 'receipt'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Uploads', + currentUrl: 'https://example.com/uploads', + dropzones: { + '#dropzone': { accepted: true }, + '#cancel-dropzone': { acceptedByCancel: true }, + }, + frames: [ + { + selector: '#upload-frame', + dropzones: { + '#frame-dropzone': { accepted: true }, + }, + }, + ], + shadowRoots: [ + { + hostSelector: 'upload-panel', + dropzones: { + '#shadow-dropzone': { accepted: true }, + }, + }, + ], + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 901, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 902, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#frame-dropzone', + files: [invoicePath], + frameSelector: '#upload-frame', + }, + }, + }, + { + jsonrpc: '2.0', + id: 903, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#shadow-dropzone', + files: [receiptPath], + pierceShadow: true, + }, + }, + }, + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { + selector: '#cancel-dropzone', + files: [invoicePath], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); + expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ + 'dragenter', + 'dragover', + 'drop', + ]); + expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + { name: 'receipt.png', size: 7, type: 'image/png' }, + ]); + expect( + getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles + ).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]); + expect( + getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles + ).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]); + expect(getResponseText(responses.find((message) => message.id === 904))).toContain( + 'status: files-dropped' + ); + expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([ + { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, + ]); + }); + + it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); + const okPath = join(tempDir, 'ok.txt'); + const missingPath = join(tempDir, 'missing.txt'); + writeFileSync(okPath, 'ok'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Uploads', + currentUrl: 'https://example.com/uploads', + dropzones: { + '#reject-dropzone': { accepted: false }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 904, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#reject-dropzone', files: [missingPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 905, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 906, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#missing-dropzone', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 907, + method: 'tools/call', + params: { + name: 'browser_drag_files', + arguments: { selector: '#reject-dropzone', files: [okPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); + expect(getResponseText(responses.find((message) => message.id === 905))).toContain( + 'pageIndex and pageId cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 906))).toContain( + 'element not found for selector: #missing-dropzone' + ); + expect(getResponseText(responses.find((message) => message.id === 907))).toContain( + 'drop target rejected files' + ); + }); + + it('drags an element to another element and to explicit coordinates', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Drag Page', + currentUrl: 'https://example.com/drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 240, + y: 60, + width: 120, + height: 80, + top: 60, + right: 360, + bottom: 140, + left: 240, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + { + id: 'page-2', + title: 'Second Drag Page', + currentUrl: 'https://example.com/drag-2', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 10, + y: 20, + width: 40, + height: 20, + top: 20, + right: 50, + bottom: 40, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 110, + y: 80, + width: 60, + height: 30, + top: 80, + right: 170, + bottom: 110, + left: 110, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 908, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#lane-b', steps: 3 }, + }, + }, + { + jsonrpc: '2.0', + id: 909, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetX: 420, targetY: 180, steps: 2 }, + }, + }, + { + jsonrpc: '2.0', + id: 916, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { pageId: 'page-2', selector: '#card-a', targetSelector: '#lane-b', steps: 1 }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged'); + expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420'); + expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180'); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b'); + expect(pages[0]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, + { type: 'mousePressed', x: 70, y: 70, button: 'left' }, + { type: 'mouseMoved', x: 147, y: 80, button: 'left' }, + { type: 'mouseMoved', x: 223, y: 90, button: 'left' }, + { type: 'mouseMoved', x: 300, y: 100, button: 'left' }, + { type: 'mouseReleased', x: 300, y: 100, button: 'left' }, + { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, + { type: 'mousePressed', x: 70, y: 70, button: 'left' }, + { type: 'mouseMoved', x: 245, y: 125, button: 'left' }, + { type: 'mouseMoved', x: 420, y: 180, button: 'left' }, + { type: 'mouseReleased', x: 420, y: 180, button: 'left' }, + ]); + expect(pages[1]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 30, y: 30, button: 'none' }, + { type: 'mousePressed', x: 30, y: 30, button: 'left' }, + { type: 'mouseMoved', x: 140, y: 95, button: 'left' }, + { type: 'mouseReleased', x: 140, y: 95, button: 'left' }, + ]); + }); + + it('rejects invalid browser_drag_element target combinations, page conflicts, and missing elements', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Drag Failures', + currentUrl: 'https://example.com/drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 910, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, + }, + }, + { + jsonrpc: '2.0', + id: 911, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#missing-source', targetX: 400, targetY: 200 }, + }, + }, + { + jsonrpc: '2.0', + id: 912, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { selector: '#card-a', targetSelector: '#missing-target' }, + }, + }, + { + jsonrpc: '2.0', + id: 917, + method: 'tools/call', + params: { + name: 'browser_drag_element', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 910))).toContain( + 'targetSelector and targetX/targetY cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 911))).toContain( + 'source element not found' + ); + expect(getResponseText(responses.find((message) => message.id === 912))).toContain( + 'target element not found' + ); + expect(getResponseText(responses.find((message) => message.id === 917))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + + it('runs minimal pointer sequences with browser_pointer_action', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Pointer Page', + currentUrl: 'https://example.com/pointer', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + { + id: 'page-2', + title: 'Second Pointer Page', + currentUrl: 'https://example.com/pointer-2', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 100, + y: 200, + width: 20, + height: 20, + top: 200, + right: 120, + bottom: 220, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 913, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down' }, + { type: 'move', x: 220, y: 160 }, + { type: 'pause', durationMs: 5 }, + { type: 'up' }, + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 918, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + pageId: 'page-2', + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down' }, + { type: 'up' }, + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed'); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed'); + expect(pages[0]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 50, y: 60, button: 'none' }, + { type: 'mousePressed', x: 50, y: 60, button: 'left' }, + { type: 'mouseMoved', x: 220, y: 160, button: 'left' }, + { type: 'mouseReleased', x: 220, y: 160, button: 'left' }, + ]); + expect(pages[1]!.drag?.recordedActions).toEqual([ + { type: 'mouseMoved', x: 110, y: 210, button: 'none' }, + { type: 'mousePressed', x: 110, y: 210, button: 'left' }, + { type: 'mouseReleased', x: 110, y: 210, button: 'left' }, + ]); + }); + + it('rejects invalid browser_pointer_action sequences and page conflicts', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Pointer Failures', + currentUrl: 'https://example.com/pointer', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 914, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'up' }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 915, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#missing-handle' }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 919, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + pageIndex: 0, + pageId: 'page-1', + actions: [{ type: 'move', x: 10, y: 10 }], + }, + }, + }, + { + jsonrpc: '2.0', + id: 920, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'right' }, + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); + expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable'); + expect(getResponseText(responses.find((message) => message.id === 919))).toContain( + 'pageIndex and pageId cannot be used together' + ); + expect(getResponseText(responses.find((message) => message.id === 920))).toContain( + 'pointer state error' + ); + }); + + it('rejects mutating browser tools when the selected page becomes stale', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-')); + const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs'); + writeFileSync( + patchedServerPath, + readFileSync(bundledServerPath, 'utf8').replace( + "let selectedPageId = '';", + "let selectedPageId = 'page-2';" + ) + ); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Primary Page', + currentUrl: 'https://example.com/primary', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 30, + y: 50, + width: 40, + height: 20, + top: 50, + right: 70, + bottom: 70, + left: 30, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 923, + method: 'tools/call', + params: { + name: 'browser_pointer_action', + arguments: { + actions: [{ type: 'move', selector: '#handle' }], + }, + }, + }, + ], + { serverPath: patchedServerPath } + ); + + expect(getResponseText(responses.find((message) => message.id === 923))).toContain( + 'Selected page is no longer available; specify pageIndex or pageId explicitly.' + ); + }); + + it('waits for a matching navigation event with browser_wait_for_event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + navigations: [{ url: 'https://example.com/checkout' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 69, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'navigation', urlIncludes: '/checkout' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 69))).toContain( + 'status: observed' + ); + }); + + it('ignores child-frame navigations when waiting for a page navigation event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + navigations: [ + { url: 'https://example.com/embedded-checkout', parentId: 'frame-1' }, + { url: 'https://example.com/checkout' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 58, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'navigation', urlIncludes: '/checkout' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 58)); + expect(text).toContain('status: observed'); + expect(text).toContain('"url":"https://example.com/checkout"'); + expect(text).not.toContain('embedded-checkout'); + }); + + it('filters download events by the selected page frame when pageIndex is provided', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'First Page', + currentUrl: 'https://example.com/one', + events: { + downloads: [ + { + url: 'https://example.com/first-report.csv', + suggestedFilename: 'first-report.csv', + frameId: 'frame-page-1', + }, + ], + }, + }, + { + id: 'page-2', + title: 'Second Page', + currentUrl: 'https://example.com/two', + events: { + downloads: [ + { + url: 'https://example.com/second-report.csv', + suggestedFilename: 'second-report.csv', + frameId: 'frame-page-2', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 59, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 1, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'second-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 59)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"second-report.csv"'); + expect(text).not.toContain('first-report.csv'); + }); + + it('matches download events from child frames within the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Framed Page', + currentUrl: 'https://example.com/frame-host', + frameTree: { + frame: { id: 'frame-page-1' }, + childFrames: [{ frame: { id: 'frame-page-1-child' } }], + }, + events: { + downloads: [ + { + url: 'https://example.com/embedded-report.csv', + suggestedFilename: 'embedded-report.csv', + frameId: 'frame-page-1-child', + }, + ], + }, + }, + { + id: 'page-2', + title: 'Other Page', + currentUrl: 'https://example.com/other', + events: { + downloads: [ + { + url: 'https://example.com/other-report.csv', + suggestedFilename: 'other-report.csv', + frameId: 'frame-page-2', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 60, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 0, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 60)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"embedded-report.csv"'); + expect(text).not.toContain('other-report.csv'); + }); + + it('refreshes frame ids when a download comes from a newly attached child frame', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Dynamic Frame Page', + currentUrl: 'https://example.com/dynamic', + frameTreeSequence: [ + { frame: { id: 'frame-page-1' } }, + { + frame: { id: 'frame-page-1' }, + childFrames: [{ frame: { id: 'frame-page-1-late-child' } }], + }, + ], + events: { + downloads: [ + { + url: 'https://example.com/late-report.csv', + suggestedFilename: 'late-report.csv', + frameId: 'frame-page-1-late-child', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 61, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + pageIndex: 0, + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'late-report' }, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 61)); + expect(text).toContain('status: observed'); + expect(text).toContain('"suggestedFilename":"late-report.csv"'); + }); + + it('captures element screenshots using the post-scroll visible clip and reports failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { + data: 'ZWxlbWVudC1zaG90', + requireScrolledMeasurement: true, + expectedClip: { + x: 0, + y: 12, + width: 50, + height: 28, + scale: 1, + }, + }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Element Screenshot Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: -14, + y: 12, + width: 64, + height: 28, + top: 12, + right: 50, + bottom: 40, + left: -14, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.zero-sized': { + exists: true, + connected: true, + rect: { + x: 10, + y: 10, + width: 0, + height: 0, + top: 10, + right: 10, + bottom: 10, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.zero-sized' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('selector: .edit-button'); + expect(text).toContain('format: png'); + expect(text).toContain('data: ZWxlbWVudC1zaG90'); + + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: element not found for selector: .missing-button' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: element has empty bounds for selector: .zero-sized' + ); + + const emptyPayloadResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Element Screenshot Empty Payload Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + rect: { + x: 220, + y: 80, + width: 64, + height: 28, + top: 80, + right: 284, + bottom: 108, + left: 220, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.edit-button' }, + }, + }, + ] + ); + + expect(getResponseText(emptyPayloadResponses.find((message) => message.id === 2))).toContain( + 'Browser MCP failed: screenshot capture failed' + ); + + const iframeScreenshotResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Iframe Screenshot Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#details-frame', + query: { + '#details-frame': { + exists: true, + connected: true, + rect: { + x: 40, + y: 100, + width: 300, + height: 200, + top: 100, + right: 340, + bottom: 300, + left: 40, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.save-button': { + exists: true, + connected: true, + rect: { + x: 10, + y: 20, + width: 60, + height: 24, + top: 20, + right: 70, + bottom: 44, + left: 10, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + screenshot: { + data: 'aWZyYW1lLXNob3Q=', + requireScrolledMeasurement: true, + expectedClip: { + x: 50, + y: 120, + width: 60, + height: 24, + scale: 1, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_take_element_screenshot', + arguments: { selector: '.save-button', frameSelector: '#details-frame' }, + }, + }, + ] + ); + + expect( + getResponseText(iframeScreenshotResponses.find((message) => message.id === 5)) + ).toContain('data: aWZyYW1lLXNob3Q='); + }); + + it('captures screenshots and reports empty payload failures', async () => { + const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: screenshotPlan, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: { fullPage: true }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('format: png'); + expect(text).toContain('fullPage: true'); + expect(text).toContain('data: c2NyZWVuc2hvdA=='); + expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); + + const errorResponses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + screenshot: { data: '' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const errorResponse = errorResponses.find((message) => message.id === 2); + expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(errorResponse)).toContain( + 'Browser MCP failed: screenshot capture failed' + ); + }); + + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + type: { + 'input[name="email"]': { + kind: 'input', + inputType: 'email', + value: 'old@example.com', + expectedValueWhenAppend: 'old@example.comhi@example.com', + requireFocus: true, + }, + '#notes': { + kind: 'textarea', + value: 'old note', + expectedValueWhenClearFirst: '', + requireFocus: true, + }, + '#editor': { + kind: 'contenteditable', + value: 'old content', + expectedValueWhenAppend: 'old contentrich text', + requireFocus: true, + }, + '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, + '#plain': { kind: 'noneditable', value: 'plain' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#notes', text: '', clearFirst: true }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#editor', text: 'rich text' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#color', text: 'ignored' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_type', + arguments: { selector: '#plain', text: 'ignored' }, + }, + }, + ] + ); + + const typed = getResponseText(responses.find((message) => message.id === 2)); + expect(typed).toContain('status: typed'); + expect(typed).toContain('selector: input[name="email"]'); + expect(typed).toContain('typedLength: 29'); + + const clearFirst = getResponseText(responses.find((message) => message.id === 3)); + expect(clearFirst).toContain('status: typed'); + expect(clearFirst).toContain('selector: #notes'); + expect(clearFirst).toContain('typedLength: 0'); + + const contenteditable = getResponseText(responses.find((message) => message.id === 4)); + expect(contenteditable).toContain('status: typed'); + expect(contenteditable).toContain('selector: #editor'); + expect(contenteditable).toContain('typedLength: 20'); + + const unsupported = responses.find((message) => message.id === 5); + expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(unsupported)).toContain( + 'element is not text-editable for selector: #color' + ); + + const nonEditable = responses.find((message) => message.id === 6); + expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(nonEditable)).toContain( + 'element is not text-editable for selector: #plain' + ); + }); +}); diff --git a/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts new file mode 100644 index 00000000..dae29085 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-downloads-and-files.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - downloads and file inputs', () => { + it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Reports', + currentUrl: 'https://example.com/reports', + browser: {}, + events: { + downloads: [ + { + guid: 'download-guid-1', + url: 'https://example.com/files/report.csv', + suggestedFilename: 'report.csv', + progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }], + }, + ], + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 57, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', eventsEnabled: true }, + }, + }, + { + jsonrpc: '2.0', + id: 58, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 59, + method: 'tools/call', + params: { + name: 'browser_cancel_download', + arguments: { guid: 'download-guid-1' }, + }, + }, + { + jsonrpc: '2.0', + id: 60, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); + expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); + expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); + expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); + expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); + }); + + it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }], + [ + { + jsonrpc: '2.0', + id: 61, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { + behavior: 'deny', + downloadPath: '/tmp/blocked-downloads', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 61); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: downloadPath is only allowed when behavior=accept' + ); + }); + + it('rejects browser_cancel_download for completed downloads', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Reports', + currentUrl: 'https://example.com/reports', + browser: {}, + events: { + downloads: [ + { + guid: 'download-guid-complete', + url: 'https://example.com/files/report.csv', + suggestedFilename: 'report.csv', + progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }], + }, + ], + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 610, + method: 'tools/call', + params: { + name: 'browser_set_download_behavior', + arguments: { behavior: 'accept', eventsEnabled: true }, + }, + }, + { + jsonrpc: '2.0', + id: 611, + method: 'tools/call', + params: { name: 'browser_list_downloads', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 612, + method: 'tools/call', + params: { + name: 'browser_cancel_download', + arguments: { guid: 'download-guid-complete' }, + }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); + const response = responses.find((message) => message.id === 612); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: download is not cancelable in status: completed' + ); + expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined(); + }); + + it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Event Page', + currentUrl: 'https://example.com/', + events: { + downloads: [ + { + guid: 'download-guid-2', + url: 'https://example.com/files/export.zip', + suggestedFilename: 'export.zip', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 62, + method: 'tools/call', + params: { + name: 'browser_wait_for_event', + arguments: { + timeoutMs: 1000, + event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' }, + }, + }, + }, + ], + { responseTimeoutMs: 12000 } + ); + + expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); + }); + + it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const invoicePath = join(tempDir, 'invoice.pdf'); + const receiptPath = join(tempDir, 'receipt.png'); + writeFileSync(invoicePath, 'invoice'); + writeFileSync(receiptPath, 'receipt'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Root Uploads', + currentUrl: 'https://example.com/root', + fileInputs: { + '#root-upload': { kind: 'file', multiple: true }, + }, + frames: [ + { + selector: '#upload-frame', + fileInputs: { + '#frame-upload': { kind: 'file', multiple: true }, + }, + }, + ], + shadowRoots: [ + { + hostSelector: 'upload-panel', + fileInputs: { + '#shadow-upload': { kind: 'file' }, + }, + }, + ], + }, + { + id: 'page-2', + title: 'Selected Uploads', + currentUrl: 'https://example.com/selected', + fileInputs: { + '#selected-upload': { kind: 'file', multiple: true }, + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 63, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 64, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 65, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { + pageIndex: 0, + selector: '#frame-upload', + files: [invoicePath], + frameSelector: '#upload-frame', + }, + }, + }, + { + jsonrpc: '2.0', + id: 66, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { + pageIndex: 0, + selector: '#shadow-upload', + files: [receiptPath], + pierceShadow: true, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); + expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); + + expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ + invoicePath, + receiptPath, + ]); + expect( + (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles + ).toEqual([invoicePath]); + expect( + (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles + ).toEqual([receiptPath]); + }); + + it('uses pageId for browser_set_file_input when provided', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const assetPath = join(tempDir, 'asset.txt'); + writeFileSync(assetPath, 'asset'); + + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Selected Uploads', + currentUrl: 'https://example.com/selected', + fileInputs: { + '#selected-upload': { kind: 'file' }, + }, + }, + { + id: 'page-2', + title: 'Explicit Uploads', + currentUrl: 'https://example.com/explicit', + fileInputs: { + '#pageid-upload': { kind: 'file' }, + }, + }, + ]; + + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 67, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } }, + }, + { + jsonrpc: '2.0', + id: 68, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); + expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ + assetPath, + ]); + expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); + }); + + it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); + const okPath = join(tempDir, 'ok.txt'); + const missingPath = join(tempDir, 'missing.txt'); + writeFileSync(okPath, 'ok'); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Upload Errors', + currentUrl: 'https://example.com/', + fileInputs: { + '#real-file-input': { kind: 'file' }, + '#not-file-input': { kind: 'nonfile' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 69, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#not-file-input', files: [okPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 70, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { selector: '#real-file-input', files: [missingPath] }, + }, + }, + { + jsonrpc: '2.0', + id: 71, + method: 'tools/call', + params: { + name: 'browser_set_file_input', + arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 69))).toContain( + 'element is not a file input for selector: #not-file-input' + ); + expect(getResponseText(responses.find((message) => message.id === 70))).toContain( + `file does not exist: ${missingPath}` + ); + expect(getResponseText(responses.find((message) => message.id === 71))).toContain( + 'pageIndex and pageId cannot be used together' + ); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts new file mode 100644 index 00000000..bbd09250 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts @@ -0,0 +1,1696 @@ +import { describe, expect, it } from 'bun:test'; +import { bundledServerPath, cpSync, getMockFrame, getMockShadowRoot, getResponseText, join, mkdtempSync, resolveNodeModulesPath, rmSync, runMcpRequests, tmpdir, writeFileSync } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - navigation and query', () => { + it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { + const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); + const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); + const bootstrapServerPath = join(installDir, 'bootstrap.cjs'); + + try { + cpSync(bundledServerPath, installedServerPath); + writeFileSync( + bootstrapServerPath, + 'delete globalThis.WebSocket;\nrequire("./ccs-browser-server.cjs");\n', + 'utf8' + ); + + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_get_url_and_title', arguments: {} }, + }, + ], + { + serverPath: bootstrapServerPath, + childEnv: { + NODE_PATH: resolveNodeModulesPath(), + }, + responseTimeoutMs: 12000, + } + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(response)).toContain('title: Installed Copy'); + expect(getResponseText(response)).toContain('url: https://example.com/'); + } finally { + rmSync(installDir, { recursive: true, force: true }); + } + }, 10000); + + it('navigates successfully after readiness polling', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/next': { + finalUrl: 'https://example.com/next', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/next' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('pageIndex: 0'); + expect(text).toContain('url: https://example.com/next'); + expect(text).toContain('status: navigated'); + }); + + it('returns a handled error when navigation readiness times out', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + navigate: { + 'https://example.com/slow': { + finalUrl: 'https://example.com/', + readyStates: new Array(60).fill('loading'), + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/slow' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); + }, 8000); + + it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { pageIndex: 1.5, url: 'https://example.com/next' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { pageIndex: 9, url: 'https://example.com/next' }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'Browser MCP failed: url is required' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'Browser MCP failed: url must be an absolute http or https URL' + ); + expect(getResponseText(responses.find((message) => message.id === 4))).toContain( + 'Browser MCP failed: pageIndex must be a non-negative integer' + ); + expect(getResponseText(responses.find((message) => message.id === 5))).toContain( + 'page index 9 is out of range' + ); + }); + + it('surfaces Page.navigate CDP failures as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/fail': { + finalUrl: 'https://example.com/start', + errorText: 'net::ERR_ABORTED', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/fail' }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 2); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' + ); + }); + + it('treats redirects as successful navigation', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/start', + navigate: { + 'https://example.com/redirect': { + finalUrl: 'https://example.com/final', + readyStates: ['loading', 'interactive'], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_navigate', + arguments: { url: 'https://example.com/redirect' }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 2)); + expect(text).toContain('status: navigated'); + expect(text).toContain('url: https://example.com/final'); + }); + + it('clicks matching elements and reports selector failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#submit': {}, + '#disabled': { disabled: true }, + '#throws': { error: 'click exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#submit' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#missing' } }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#disabled' } }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#throws' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #submit' + ); + + const selectorMiss = responses.find((message) => message.id === 3); + expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(selectorMiss)).toContain( + 'element index 0 is out of range for selector: #missing' + ); + + const disabledError = responses.find((message) => message.id === 4); + expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); + + const pageSideError = responses.find((message) => message.id === 5); + expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(pageSideError)).toContain('click exploded'); + }); + + it('clicks the requested zero-based match and rejects out-of-range nth', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Click Page', + currentUrl: 'https://example.com/', + click: { + '.menu-item': [{ label: 'first' }, { label: 'second' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 20, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 1 } }, + }, + { + jsonrpc: '2.0', + id: 21, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 3 } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 20))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 20))).toContain('nth: 1'); + expect(getResponseText(responses.find((message) => message.id === 21))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .menu-item' + ); + }); + + it('reports detached and hidden click targets as handled errors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#hidden': { hidden: true }, + '#detached': { detached: true }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#hidden' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#detached' } }, + }, + ] + ); + + const hiddenError = responses.find((message) => message.id === 2); + expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hiddenError)).toContain( + 'element is hidden or not interactable for selector: #hidden' + ); + + const detachedError = responses.find((message) => message.id === 3); + expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); + }); + + it('uses a mouse sequence when the target requires it', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#menu-trigger': { requireMouseSequence: true }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #menu-trigger' + ); + }); + + it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#click-event': { + requireMouseSequence: true, + forbidSyntheticClickEvent: true, + requireNativeClick: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#click-event' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #click-event' + ); + }); + + it('preserves native click activation after the mouse sequence', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#native-click': { + requireMouseSequence: true, + requireNativeClick: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#native-click' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #native-click'); + }); + + it('does not force activation when mousedown cancels the interaction', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#cancel-mousedown': { + requireMouseSequence: true, + cancelMouseDown: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); + }); + + it('rechecks connectivity before native activation after the mouse sequence', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#detached-during-click': { + requireMouseSequence: true, + requireNativeClick: true, + detachAfterMouseDown: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 2); + expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(clickResponse)).toContain('status: clicked'); + expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); + }); + + it('falls back to click when mouse sequence dispatch fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Example Page', + currentUrl: 'https://example.com/', + click: { + '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'browser_click', arguments: { selector: '#fallback' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'status: clicked' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'selector: #fallback' + ); + }); + + it('clicks with element-relative offsets and richer click options', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Click Page', + currentUrl: 'https://example.com/', + click: { + '#offset-target': { + requireMouseSequence: true, + expectedOffset: { x: 12, y: 8 }, + expectedButton: 'right', + expectedClickCount: 2, + }, + '#double-left': { + requireMouseSequence: true, + expectedButton: 'left', + expectedClickCount: 2, + requireDoubleClickEvent: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 70, + method: 'tools/call', + params: { + name: 'browser_click', + arguments: { + selector: '#offset-target', + offsetX: 12, + offsetY: 8, + button: 'right', + clickCount: 2, + }, + }, + }, + { + jsonrpc: '2.0', + id: 700, + method: 'tools/call', + params: { + name: 'browser_click', + arguments: { + selector: '#double-left', + clickCount: 2, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 70)); + expect(text).toContain('status: clicked'); + expect(text).toContain('offsetX: 12'); + expect(text).toContain('offsetY: 8'); + expect(text).toContain('button: right'); + expect(text).toContain('clickCount: 2'); + + const doubleText = getResponseText(responses.find((message) => message.id === 700)); + expect(doubleText).toContain('status: clicked'); + expect(doubleText).toContain('button: left'); + expect(doubleText).toContain('clickCount: 2'); + }); + + it('presses a key combination with browser_press_key', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Keyboard Page', + currentUrl: 'https://example.com/', + keyboard: { + expectedKey: 'k', + expectedModifiers: ['Meta'], + expectedRepeat: 2, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 71, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'k', + modifiers: ['Meta'], + repeat: 2, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 71)); + expect(text).toContain('status: key-pressed'); + expect(text).toContain('key: k'); + expect(text).toContain('modifiers: Meta'); + expect(text).toContain('repeat: 2'); + }); + + it('supports common special keys with browser_press_key', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Special Key Page', + currentUrl: 'https://example.com/', + keyboard: { + expectedKey: 'Enter', + expectedRepeat: 1, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 711, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'Enter', + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 711)); + expect(text).toContain('status: key-pressed'); + expect(text).toContain('key: Enter'); + }); + + it('rejects unsupported modifiers for browser_press_key', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Invalid Modifier Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 712, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'k', + modifiers: ['Cmd'], + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 712); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'modifiers must only contain: Alt, Control, Meta, Shift' + ); + }); + + it('scrolls an element into view with browser_scroll', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Scroll Page', + currentUrl: 'https://example.com/', + scroll: { + '#results': { + expectedBehavior: 'into-view', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 72, + method: 'tools/call', + params: { + name: 'browser_scroll', + arguments: { + selector: '#results', + behavior: 'into-view', + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 72)); + expect(text).toContain('status: scrolled'); + expect(text).toContain('selector: #results'); + expect(text).toContain('behavior: into-view'); + }); + + it('scrolls an iframe page by offset with browser_scroll', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Iframe Scroll Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#preview-frame', + query: { + '#preview-frame': { + exists: true, + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 73, + method: 'tools/call', + params: { + name: 'browser_scroll', + arguments: { + frameSelector: '#preview-frame', + behavior: 'by-offset', + deltaX: 5, + deltaY: 40, + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 73)); + expect(text).toContain('status: scrolled'); + expect(text).toContain('behavior: by-offset'); + expect(text).toContain('deltaX: 5'); + expect(text).toContain('deltaY: 40'); + }); + + it('requires real mouse movement for hover-only targets and reports hover failures', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Hover Page', + currentUrl: 'https://example.com/', + hover: { + '.draft-card': { requireCdpMouseMove: true }, + '.missing-bounds': { zeroSized: true }, + }, + query: { + '.draft-card': { + exists: true, + connected: true, + rect: { + x: 100, + y: 40, + width: 120, + height: 48, + top: 40, + right: 220, + bottom: 88, + left: 100, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-bounds': { + exists: true, + connected: true, + rect: { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.draft-card' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_hover', + arguments: { selector: '.missing-bounds' }, + }, + }, + ] + ); + + const hoverResponse = responses.find((message) => message.id === 2); + expect((hoverResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(hoverResponse)).toContain('status: hovered'); + expect(getResponseText(hoverResponse)).toContain('selector: .draft-card'); + + const hoverError = responses.find((message) => message.id === 3); + expect((hoverError?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(hoverError)).toContain( + 'element is hidden or not interactable for selector: .missing-bounds' + ); + }); + + it('queries element diagnostics and validates query fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.edit-button': { + exists: true, + connected: true, + innerText: 'Edit', + textContent: 'Edit', + rect: { + x: 123, + y: 45, + width: 48, + height: 24, + top: 45, + right: 171, + bottom: 69, + left: 123, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '.missing-button': { + exists: false, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.edit-button', + fields: ['display', 'visibility', 'opacity'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.missing-button' }, + }, + }, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: 'display' }, + }, + }, + { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { selector: '.edit-button', fields: ['displayMode'] }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'exists: true' + ); + expect(getResponseText(responses.find((message) => message.id === 2))).toContain( + 'boundingClientRect: {"x":123' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).toContain( + 'display: block' + ); + expect(getResponseText(responses.find((message) => message.id === 3))).not.toContain( + 'innerText:' + ); + + const missingResponse = responses.find((message) => message.id === 4); + expect((missingResponse?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(missingResponse)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 5))).toContain( + 'Browser MCP failed: fields must be an array of strings' + ); + expect(getResponseText(responses.find((message) => message.id === 6))).toContain( + 'Browser MCP failed: unknown query field: displayMode' + ); + }); + + it('reports count, nth-aware fields, href, and onclick for multi-match selectors', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + href: 'https://example.com/open', + onclick: 'openCard()', + }, + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '0.95', + href: 'https://example.com/archive', + onclick: 'archiveCard()', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 1, + fields: ['count', 'exists', 'innerText', 'href', 'onclick'], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 30)); + expect(text).toContain('count: 2'); + expect(text).toContain('exists: true'); + expect(text).toContain('innerText: Archive'); + expect(text).toContain('href: https://example.com/archive'); + expect(text).toContain('onclick: archiveCard()'); + expect(text).toContain('nth: 1'); + }); + + it('keeps count and exists available when nth is out of range, but rejects target fields', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Query Page', + currentUrl: 'https://example.com/', + query: { + '.hover-action': [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 31, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'exists'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 32, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.hover-action', + nth: 3, + fields: ['count', 'innerText'], + }, + }, + }, + ] + ); + + const countOnly = responses.find((message) => message.id === 31); + expect((countOnly?.result as { isError?: boolean }).isError).not.toBe(true); + expect(getResponseText(countOnly)).toContain('count: 1'); + expect(getResponseText(countOnly)).toContain('exists: false'); + + expect(getResponseText(responses.find((message) => message.id === 32))).toContain( + 'Browser MCP failed: element index 3 is out of range for selector: .hover-action' + ); + }); + + it('waits for selector visibility with opacity threshold', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Page', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + { exists: false }, + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 40, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 40))).toContain( + 'status: satisfied' + ); + }); + + it('waits for page text includes and returns timeout summaries', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Timeout', + currentUrl: 'https://example.com/', + wait: { + pageTextSequence: ['loading', 'loading', 'loaded archive menu'], + selectorSnapshots: { + '.hover-action': [ + { + exists: true, + connected: true, + display: 'block', + visibility: 'visible', + opacity: '0.2', + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, + }, + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 41, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'archive menu' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 30, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 41))).toContain( + 'status: satisfied' + ); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'Browser MCP failed: wait condition timed out' + ); + expect(getResponseText(responses.find((message) => message.id === 42))).toContain( + 'opacity=0.2' + ); + }); + + it('waits for selector text includes', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Text', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + [ + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 45, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'text', includes: 'Archive' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 45))).toContain( + 'status: satisfied' + ); + }); + + it('lets nth become valid later during polling and rejects unsupported page-level wait kinds', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Wait Nth', + currentUrl: 'https://example.com/', + wait: { + selectorSnapshots: { + '.hover-action': [ + [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + [ + { + exists: true, + connected: true, + innerText: 'Open', + textContent: 'Open', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 10, + y: 10, + width: 40, + height: 20, + top: 10, + right: 50, + bottom: 30, + left: 10, + }, + }, + ], + ], + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 43, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + selector: '.hover-action', + nth: 1, + timeoutMs: 1000, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + { + jsonrpc: '2.0', + id: 44, + method: 'tools/call', + params: { + name: 'browser_wait_for', + arguments: { + timeoutMs: 100, + pollIntervalMs: 10, + condition: { kind: 'visibility', visibility: 'visible' }, + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 43))).toContain( + 'status: satisfied' + ); + expect(getResponseText(responses.find((message) => message.id === 44))).toContain( + 'Browser MCP failed: page-level wait only supports text conditions in Phase 1' + ); + }); + + it('defaults browser_eval to readonly and enforces readwrite gating', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'JSON.stringify({ hovered: true, label: "Archive" })': { + result: { hovered: true, label: 'Archive' }, + }, + 'document.body.dataset.test = "1"': { + error: 'EvalError: Possible side-effect in debug-evaluate', + }, + 'throw new Error("boom")': { + error: 'boom', + }, + Infinity: { + unserializableValue: 'Infinity', + }, + window: { + nonSerializable: true, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 50, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'JSON.stringify({ hovered: true, label: "Archive" })' }, + }, + }, + { + jsonrpc: '2.0', + id: 51, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"' }, + }, + }, + { + jsonrpc: '2.0', + id: 52, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + { + jsonrpc: '2.0', + id: 53, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'throw new Error("boom")' }, + }, + }, + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'Infinity' }, + }, + }, + { + jsonrpc: '2.0', + id: 55, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'window' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readonly' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 50))).toContain( + 'mode: readonly' + ); + expect(getResponseText(responses.find((message) => message.id === 50))).toContain( + 'value: {"hovered":true,"label":"Archive"}' + ); + expect(getResponseText(responses.find((message) => message.id === 51))).toContain( + 'Browser MCP failed: EvalError: Possible side-effect in debug-evaluate' + ); + expect(getResponseText(responses.find((message) => message.id === 52))).toContain( + 'Browser MCP failed: browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=readonly' + ); + expect(getResponseText(responses.find((message) => message.id === 53))).toContain( + 'Browser MCP failed: boom' + ); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'value: "Infinity"' + ); + expect(getResponseText(responses.find((message) => message.id === 55))).toContain( + 'Browser MCP failed: evaluation result is not JSON-serializable' + ); + }); + + it('allows readwrite browser_eval when configured', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Eval Page', + currentUrl: 'https://example.com/', + eval: { + 'document.body.dataset.test = "1"': { + result: 'ok', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 54, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readwrite' } } + ); + + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'mode: readwrite' + ); + expect(getResponseText(responses.find((message) => message.id === 54))).toContain( + 'value: "ok"' + ); + }); + + it('hides browser_eval when eval mode is disabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Eval Disabled Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 56, + method: 'tools/list', + params: {}, + }, + { + jsonrpc: '2.0', + id: 57, + method: 'tools/call', + params: { + name: 'browser_eval', + arguments: { expression: '1 + 1' }, + }, + }, + ], + { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'disabled' } } + ); + + const tools = (responses.find((message) => message.id === 56)?.result as { tools?: Array<{ name?: string }> }) + ?.tools || []; + expect(tools.some((tool) => tool.name === 'browser_eval')).toBe(false); + expect((responses.find((message) => message.id === 57)?.error as { message?: string })?.message).toBe( + 'Unknown tool: browser_eval' + ); + }); + + it('queries inside an iframe selected by frameSelector', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Frame Page', + currentUrl: 'https://example.com/', + frames: [ + { + selector: '#details-frame', + query: { + '.save-button': { + exists: true, + connected: true, + innerText: 'Save', + textContent: 'Save', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 55, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: '.save-button', + frameSelector: '#details-frame', + fields: ['exists', 'innerText'], + }, + }, + }, + ] + ); + + expect( + getMockFrame( + { + frames: [{ selector: '#details-frame' }], + id: 'x', + title: 'x', + currentUrl: 'https://example.com/', + }, + '#details-frame' + ) + ).toBeDefined(); + expect(getResponseText(responses.find((message) => message.id === 55))).toContain( + 'innerText: Save' + ); + }); + + it('queries through open shadow roots when pierceShadow is true', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Shadow Page', + currentUrl: 'https://example.com/', + shadowRoots: [ + { + hostSelector: 'app-toolbar', + query: { + 'button[action="archive"]': { + exists: true, + connected: true, + innerText: 'Archive', + textContent: 'Archive', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + }, + ], + [ + { + jsonrpc: '2.0', + id: 56, + method: 'tools/call', + params: { + name: 'browser_query', + arguments: { + selector: 'button[action="archive"]', + pierceShadow: true, + fields: ['exists', 'innerText'], + }, + }, + }, + ] + ); + + expect( + getMockShadowRoot({ + shadowRoots: [{ hostSelector: 'app-toolbar' }], + id: 'x', + title: 'x', + currentUrl: 'https://example.com/', + }) + ).toBeDefined(); + expect(getResponseText(responses.find((message) => message.id === 56))).toContain( + 'innerText: Archive' + ); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts b/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts new file mode 100644 index 00000000..4ba36192 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-orchestration-and-artifacts.test.ts @@ -0,0 +1,1765 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, createReplayStep, createOrchestrationBlock, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - orchestration and artifacts', () => { + it('starts orchestration, reports progress, and completes a wait_then_click flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Page', + currentUrl: 'https://example.com/orchestration', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1201, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { + selector: '#menu', + condition: { kind: 'visibility' }, + timeoutMs: 1000, + }, + click: { + selector: '#menu', + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); + }); + + it('cancels orchestration before later blocks run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Cancel Page', + currentUrl: 'https://example.com/orchestration-cancel', + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + rect: { + x: 80, + y: 30, + width: 80, + height: 20, + top: 30, + right: 160, + bottom: 50, + left: 80, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1202_1, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202_2, + method: 'tools/call', + params: { name: 'browser_cancel_orchestration', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1202_3, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1202_3)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedBlocks: 2'); + }); + + it('completes a wait_then_type flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Type Orchestration Page', + currentUrl: 'https://example.com/type-orchestration', + wait: { + selectorSnapshots: { + '#email': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1203, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_type', + args: { + wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + type: { selector: '#email', text: 'walker@example.com', clearFirst: true }, + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1'); + }); + + it('completes a run_replay_sequence block', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Sequence Orchestration Page', + currentUrl: 'https://example.com/orchestration-replay', + click: { '#submit': {} }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1211, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed'); + }); + + it('completes an assert_query block', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1212, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assert: { field: 'innerText', equals: 'ready' }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed'); + }); + + it('fails orchestration on the first failing block', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }], + [ + { + jsonrpc: '2.0', + id: 1213, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assert: { field: 'exists', equals: 'true' }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1214, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1214)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + }); + + it('runs a single-layer sequence with two successful blocks', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Page', + currentUrl: 'https://example.com/sequence', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1401, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1'); + }); + + it('reports failedSequenceStepIndex when a sequence step fails', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Failure Page', + currentUrl: 'https://example.com/sequence-failure', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1402, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1403, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1403)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedSequenceStepIndex: 1'); + }); + + it('rejects empty sequence steps and nested sequence blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }], + [ + { + jsonrpc: '2.0', + id: 1404, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1405, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks'); + }); + + it('runs a top-level block followed by a sequence in order', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Mixed Sequence Page', + currentUrl: 'https://example.com/mixed-sequence', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1406, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + click: { selector: '#menu' }, + }, + }), + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2'); + }); + + it('passes assert_query with multiple structured assertions', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '0.95', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1301, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['exists', 'innerText', 'opacity'] }, + assertions: [ + { field: 'exists', op: 'equals', value: true }, + { field: 'innerText', op: 'contains', value: 'ready' }, + { field: 'opacity', op: 'gte', value: 0.9 }, + ], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1301))).toContain('status: completed'); + }); + + it('returns failedAssertionIndex, expected, and actual when assert_query fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Failure Page', + currentUrl: 'https://example.com/assert-failure', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'loading', + textContent: 'loading', + display: 'block', + visibility: 'visible', + opacity: '0.42', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1302, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText', 'opacity'] }, + assertions: [ + { field: 'innerText', op: 'contains', value: 'ready' }, + { field: 'opacity', op: 'gte', value: 0.9 }, + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1303, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1303)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + expect(text).toContain('failedAssertionIndex: 0'); + expect(text).toContain('field: innerText'); + expect(text).toContain('expected: "ready"'); + expect(text).toContain('actual: "loading"'); + }); + + it('fails when numeric comparison is used on a non-number-like field or when op is unsupported', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Type Error Page', + currentUrl: 'https://example.com/assert-type-error', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'loading', + textContent: 'loading', + opacity: '0.42', + display: 'block', + visibility: 'visible', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1304, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'gte', value: 1 }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1305, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['opacity'] }, + assertions: [{ field: 'opacity', op: 'matches', value: '.*' }], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1304))).toContain('numeric comparison expects a number-like field: innerText'); + expect(getResponseText(responses.find((message) => message.id === 1305))).toContain('unsupported assertion operator'); + }); + + it('passes numeric gte and lt assertions', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Numeric Assertion Page', + currentUrl: 'https://example.com/assert-numeric', + query: { + '#status': { + exists: true, + connected: true, + opacity: '0.95', + display: 'block', + visibility: 'visible', + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1306, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['opacity'] }, + assertions: [ + { field: 'opacity', op: 'gte', value: 0.9 }, + { field: 'opacity', op: 'lt', value: 1.0 }, + ], + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1306))).toContain('status: completed'); + }); + + it('continues to the next top-level block when continueOnError is true', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Policy Page', + currentUrl: 'https://example.com/policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1501, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1502, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1502)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].blockIndex: 0'); + }); + + it('continues inside sequence when a step has continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Policy Page', + currentUrl: 'https://example.com/sequence-policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1503, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1504, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1504)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 1'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('still stops immediately when continueOnError is not enabled', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Strict Stop Page', currentUrl: 'https://example.com/strict-stop' }], + [ + { + jsonrpc: '2.0', + id: 1505, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + continueOnError: true, + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1505)); + expect(text).toContain('status: failed'); + expect(text).toContain('completedBlocks: 0'); + }); + + it('continues to the next top-level block when a sequence block itself allows continueOnError', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Sequence Top-Level Policy Page', + currentUrl: 'https://example.com/sequence-top-policy', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1506, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'sequence', + continueOnError: true, + args: { + steps: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assertions: [{ field: 'exists', op: 'equals', value: true }], + }, + }), + ], + }, + }), + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1507, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + const text = getResponseText(responses.find((message) => message.id === 1507)); + expect(text).toContain('status: completed_with_failures'); + expect(text).toContain('completedBlocks: 2'); + expect(text).toContain('failedCount: 1'); + expect(text).toContain('failure[0].sequenceStepIndex: 0'); + }); + + it('exports recording and replay artifacts and lists them', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + timestamp: 1710000000000, + }, + ], + }, + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { + jsonrpc: '2.0', + id: 1603, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1604, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1605, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1606, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported'); + const listText = getResponseText(responses.find((message) => message.id === 1606)); + expect(listText).toContain('name: rec-smoke'); + expect(listText).toContain('name: replay-smoke'); + }); + + it('imports and deletes orchestration artifacts', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + click: { '#submit': {} }, + wait: { + selectorSnapshots: { + '#submit': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1607, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1608, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1609, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1610, + method: 'tools/call', + params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1611, + method: 'tools/call', + params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported'); + expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted'); + }); + + it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-')); + const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json'); + writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a')); + + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Failure Page', + currentUrl: 'https://example.com/artifact-failure', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, + { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, + { jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } }, + { jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } }, + { jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); + expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); + expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); + expect(getResponseText(responses.find((message) => message.id === 1618))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1619))).toContain( + 'artifact name must start with a letter or number' + ); + expect(getResponseText(responses.find((message) => message.id === 1620))).toContain( + 'artifact file exceeds maximum size' + ); + }); + + it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1701, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 1 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); + }); + + it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1702, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'open_page_then_run', + args: { + open: { url: 'https://example.com/docs' }, + run: { + type: 'wait_for_then_press_key', + args: { + wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, + pressKey: { key: 'Enter' }, + }, + }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); + }); + + it('runs close_page_then_continue and then continues with the remaining blocks', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, + { + jsonrpc: '2.0', + id: 1704, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'close_page_then_continue', + args: { close: { pageId: 'page-2' } }, + }), + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + continueOnError: true, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); + }); + + it('rejects missing run payloads and nested cross-page blocks', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1705, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1706, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageIndex: 0 }, + run: createOrchestrationBlock({ + type: 'open_page_then_run', + args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, + }), + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); + expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); + }); + + it('fails when select_page_then_run targets a missing page', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1707, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'select_page_then_run', + args: { + select: { pageId: 'page-9' }, + run: { + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1708, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1708)); + expect(text).toContain('status: failed'); + expect(text).toContain('page not found: page-9'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts b/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts new file mode 100644 index 00000000..782108d4 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-recording-and-replay.test.ts @@ -0,0 +1,626 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText, createReplayStep } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - recording and replay', () => { + it('starts, stops, reads, and clears a recording session', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Recording Page', + currentUrl: 'https://example.com/recording', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + button: 'left', + clickCount: 1, + offsetX: 12, + offsetY: 8, + timestamp: 1710000000000, + }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1001))).toContain('status: recording'); + expect(getResponseText(responses.find((message) => message.id === 1002))).toContain('status: stopped'); + expect(getResponseText(responses.find((message) => message.id === 1003))).toContain('type: click'); + expect(getResponseText(responses.find((message) => message.id === 1004))).toContain('status: cleared'); + }); + + it('rejects invalid recording lifecycle operations', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Recording Page', + currentUrl: 'https://example.com/recording', + }, + ], + [ + { jsonrpc: '2.0', id: 1011, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1012, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1013, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1014, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1015, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1016, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1012))).toContain('recording already active'); + expect(getResponseText(responses.find((message) => message.id === 1014))).toContain('no active recording'); + expect(getResponseText(responses.find((message) => message.id === 1016))).toContain('no recording available'); + }); + + it('routes recording start by pageId and rejects page conflicts', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'First', currentUrl: 'https://example.com/1' }, + { id: 'page-2', title: 'Second', currentUrl: 'https://example.com/2' }, + ], + [ + { + jsonrpc: '2.0', + id: 1017, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 1018, + method: 'tools/call', + params: { name: 'browser_clear_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: { pageIndex: 0, pageId: 'page-1' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1017))).toContain('pageIndex: 1'); + expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); + }); + + it('cleans up recording state when stop finalization fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Stop Recording Page', + currentUrl: 'https://example.com/broken-stop-recording', + recording: { + finalizeError: 'recording finalize failed', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1019_1, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_2, + method: 'tools/call', + params: { name: 'browser_stop_recording', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1019_3, + method: 'tools/call', + params: { name: 'browser_start_recording', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain( + 'recording finalize failed' + ); + expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain( + 'status: recording' + ); + }); + + it('rolls back recording state when recorder injection fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Broken Recording Page', + currentUrl: 'https://example.com/broken-recording', + recording: { + injectionError: 'recording injection failed', + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed'); + expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available'); + }); + + it('normalizes type, press_key, scroll, and warnings in a recording result', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Normalize Page', + currentUrl: 'https://example.com/normalize', + recording: { + events: [ + { kind: 'type', selector: '#email', text: 'walker@example.com', timestamp: 1710000000100 }, + { kind: 'press_key', key: 'Enter', modifiers: ['Shift'], timestamp: 1710000000200 }, + { kind: 'scroll', selector: '#results', deltaX: 0, deltaY: 320, timestamp: 1710000000300 }, + ], + warnings: [{ message: 'cross-origin frame events were skipped' }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1021, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1022, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1023, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1023)); + expect(text).toContain('type: type'); + expect(text).toContain('selector: #email'); + expect(text).toContain('type: press_key'); + expect(text).toContain('type: scroll'); + expect(text).toContain('cross-origin frame events were skipped'); + }); + + it('normalizes drag_element and pointer_action recordings', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Drag Recording Page', + currentUrl: 'https://example.com/drag-recording', + recording: { + events: [ + { + kind: 'drag_element', + selector: '#card-a', + targetSelector: '#lane-b', + timestamp: 1710000000400, + }, + { + kind: 'pointer_action', + actions: [ + { type: 'move', x: 10, y: 20 }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'left' }, + ], + timestamp: 1710000000500, + }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1031, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1032, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1033, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1033)); + expect(text).toContain('type: drag_element'); + expect(text).toContain('selector: #card-a'); + expect(text).toContain('targetSelector: "#lane-b"'); + expect(text).toContain('type: pointer_action'); + expect(text).toContain('actions:'); + }); + + it('stops recording with a warning when the target page becomes unavailable', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Closing Page', + currentUrl: 'https://example.com/closing', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000600 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1034, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1035, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-1' } } }, + { jsonrpc: '2.0', id: 1036, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1036)); + expect(text).toContain('status: stopped'); + expect(text).toContain('recording stopped because target page was closed'); + }); + + it('starts a replay, reports progress, and completes basic steps', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Page', + currentUrl: 'https://example.com/replay', + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + scroll: { + '#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1101, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + nth: 0, + args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 }, + }), + createReplayStep({ + type: 'type', + pageId: 'page-1', + selector: '#email', + nth: 0, + args: { text: 'walker@example.com' }, + }), + createReplayStep({ + type: 'scroll', + pageId: 'page-1', + selector: '#results', + args: { deltaX: 0, deltaY: 240 }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1102, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3'); + expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed'); + }); + + it('rejects invalid replay payloads before execution starts', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }], + [ + { + jsonrpc: '2.0', + id: 1111, + method: 'tools/call', + params: { name: 'browser_start_replay', arguments: { steps: [] } }, + }, + { + jsonrpc: '2.0', + id: 1112, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1113, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })], + pageId: 'page-1', + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array'); + expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type'); + expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch'); + }); + + it('fails replay on the first failing step and reports failedStepIndex', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Replay Failure Page', + currentUrl: 'https://example.com/replay-failure', + click: { + '#submit': {}, + }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1114, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }), + createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1115, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1115)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedStepIndex: 1'); + expect(text).toContain('element index 0 is out of range for selector: #missing'); + }); + + it('cancels a replay before later steps run', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Cancel Page', + currentUrl: 'https://example.com/replay-cancel', + click: { + '#submit': {}, + }, + query: { + '#handle': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 40, + height: 40, + top: 30, + right: 60, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#submit': { + exists: true, + connected: true, + rect: { + x: 120, + y: 30, + width: 100, + height: 40, + top: 30, + right: 220, + bottom: 70, + left: 120, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1116, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', selector: '#handle' }, + { type: 'pause', durationMs: 400 }, + ], + }, + }), + createReplayStep({ + type: 'click', + pageId: 'page-1', + selector: '#submit', + args: {}, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1117, + method: 'tools/call', + params: { name: 'browser_cancel_replay', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1118, + method: 'tools/call', + params: { name: 'browser_get_replay', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1116))).toContain( + 'status: running' + ); + const text = getResponseText(responses.find((message) => message.id === 1118)); + expect(text).toContain('status: canceled'); + expect(text).not.toContain('completedSteps: 2'); + }); + + it('replays drag_element and pointer_action steps successfully', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Drag Page', + currentUrl: 'https://example.com/replay-drag', + query: { + '#card-a': { + exists: true, + connected: true, + rect: { + x: 20, + y: 40, + width: 100, + height: 60, + top: 40, + right: 120, + bottom: 100, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + '#lane-b': { + exists: true, + connected: true, + rect: { + x: 240, + y: 60, + width: 120, + height: 80, + top: 60, + right: 360, + bottom: 140, + left: 240, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1121, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [ + createReplayStep({ + type: 'drag_element', + pageId: 'page-1', + selector: '#card-a', + args: { targetSelector: '#lane-b' }, + }), + createReplayStep({ + type: 'pointer_action', + pageId: 'page-1', + args: { + actions: [ + { type: 'move', x: 10, y: 20 }, + { type: 'down', button: 'left' }, + { type: 'up', button: 'left' }, + ], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts new file mode 100644 index 00000000..554a25c7 --- /dev/null +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -0,0 +1,1817 @@ +import { describe, expect, it } from 'bun:test'; +import { runMcpRequests, getResponseText } from './browser-mcp-test-harness'; +import type { MockPageState } from './browser-mcp-test-harness'; + +describe('ccs-browser MCP server - session and interception', () => { + it('lists browser tools including navigate, click, type, and screenshot', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], + [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] + ); + + const tools = ( + responses.find((message) => message.id === 2)?.result as { + tools: Array<{ + name: string; + description?: string; + inputSchema?: { properties?: Record }; + }>; + } + ).tools; + + expect(tools.map((tool) => tool.name)).toEqual([ + 'browser_get_session_info', + 'browser_get_url_and_title', + 'browser_get_visible_text', + 'browser_get_dom_snapshot', + 'browser_navigate', + 'browser_click', + 'browser_type', + 'browser_press_key', + 'browser_scroll', + 'browser_select_page', + 'browser_open_page', + 'browser_close_page', + 'browser_add_intercept_rule', + 'browser_remove_intercept_rule', + 'browser_list_intercept_rules', + 'browser_list_requests', + 'browser_set_download_behavior', + 'browser_list_downloads', + 'browser_cancel_download', + 'browser_set_file_input', + 'browser_drag_files', + 'browser_drag_element', + 'browser_pointer_action', + 'browser_start_recording', + 'browser_stop_recording', + 'browser_get_recording', + 'browser_clear_recording', + 'browser_start_replay', + 'browser_get_replay', + 'browser_cancel_replay', + 'browser_start_orchestration', + 'browser_get_orchestration', + 'browser_cancel_orchestration', + 'browser_export_artifact', + 'browser_import_artifact', + 'browser_list_artifacts', + 'browser_delete_artifact', + 'browser_take_screenshot', + 'browser_wait_for', + 'browser_eval', + 'browser_hover', + 'browser_query', + 'browser_take_element_screenshot', + 'browser_wait_for_event', + ]); + + const clickTool = tools.find((tool) => tool.name === 'browser_click'); + expect(clickTool?.description).toContain('mouse event chain'); + expect(clickTool?.description).not.toContain('synthetic element.click()'); + expect(clickTool?.inputSchema?.properties?.offsetX).toMatchObject({ type: 'number' }); + expect(clickTool?.inputSchema?.properties?.offsetY).toMatchObject({ type: 'number' }); + expect(clickTool?.inputSchema?.properties?.button).toMatchObject({ type: 'string' }); + expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({ + type: 'integer', + minimum: 1, + }); + + const keyTool = tools.find((tool) => tool.name === 'browser_press_key'); + expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' }); + expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' }); + + const scrollTool = tools.find((tool) => tool.name === 'browser_scroll'); + expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' }); + expect(scrollTool?.inputSchema?.properties?.deltaY).toMatchObject({ type: 'number' }); + + const selectTool = tools.find((tool) => tool.name === 'browser_select_page'); + expect(selectTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(selectTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const openTool = tools.find((tool) => tool.name === 'browser_open_page'); + expect(openTool?.inputSchema?.properties?.url).toMatchObject({ type: 'string' }); + + const closeTool = tools.find((tool) => tool.name === 'browser_close_page'); + expect(closeTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(closeTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const addRuleTool = tools.find((tool) => tool.name === 'browser_add_intercept_rule'); + expect(addRuleTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.urlIncludes).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.method).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.resourceType).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.urlPattern).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.urlRegex).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.headerMatchers).toMatchObject({ type: 'array' }); + expect(addRuleTool?.inputSchema?.properties?.priority).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.statusCode).toMatchObject({ type: 'integer' }); + expect(addRuleTool?.inputSchema?.properties?.responseHeaders).toMatchObject({ type: 'array' }); + expect(addRuleTool?.inputSchema?.properties?.headers).toBeUndefined(); + expect(addRuleTool?.inputSchema?.properties?.body).toMatchObject({ type: 'string' }); + expect(addRuleTool?.inputSchema?.properties?.contentType).toMatchObject({ type: 'string' }); + + const removeRuleTool = tools.find((tool) => tool.name === 'browser_remove_intercept_rule'); + expect(removeRuleTool?.inputSchema?.properties?.ruleId).toMatchObject({ type: 'string' }); + + const listRulesTool = tools.find((tool) => tool.name === 'browser_list_intercept_rules'); + expect(listRulesTool?.inputSchema).toMatchObject({ type: 'object' }); + + const listRequestsTool = tools.find((tool) => tool.name === 'browser_list_requests'); + expect(listRequestsTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(listRequestsTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(listRequestsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); + + const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior'); + expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' }); + expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' }); + expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' }); + + const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads'); + expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); + expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined(); + + const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download'); + expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' }); + expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' }); + + const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input'); + expect(uploadTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); + expect(uploadTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); + expect(uploadTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(uploadTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(uploadTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); + expect(uploadTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); + expect(uploadTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); + + const dragFilesTool = tools.find((tool) => tool.name === 'browser_drag_files'); + expect(dragFilesTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); + expect(dragFilesTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); + expect(dragFilesTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(dragFilesTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + expect(dragFilesTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); + expect(dragFilesTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); + expect(dragFilesTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); + + const dragElementTool = tools.find((tool) => tool.name === 'browser_drag_element'); + expect(dragElementTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); + expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ type: 'string' }); + expect(dragElementTool?.inputSchema?.properties?.targetX).toMatchObject({ type: 'number' }); + expect(dragElementTool?.inputSchema?.properties?.targetY).toMatchObject({ type: 'number' }); + expect(dragElementTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'integer' }); + + const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action'); + expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' }); + + const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording'); + expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording'); + expect(stopRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const getRecordingTool = tools.find((tool) => tool.name === 'browser_get_recording'); + expect(getRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording'); + expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); + + const startReplayTool = tools.find((tool) => tool.name === 'browser_start_replay'); + expect(startReplayTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'array' }); + expect(startReplayTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startReplayTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const getReplayTool = tools.find((tool) => tool.name === 'browser_get_replay'); + expect(getReplayTool?.inputSchema).toMatchObject({ type: 'object' }); + + const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay'); + expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' }); + + const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); + expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); + expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + + const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); + expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + + const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); + expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' }); + expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact'); + expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' }); + + const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts'); + expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' }); + + const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact'); + expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + const queryTool = tools.find((tool) => tool.name === 'browser_query'); + expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ + type: 'array', + }); + + for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { + expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ + type: 'integer', + minimum: 0, + }); + } + }); + + it('marks the selected page in browser_get_session_info', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 801, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 802, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 802)); + expect(text).toContain('selected: true'); + expect(text).toContain('1. Docs'); + }); + + it('uses the selected page when pageIndex is omitted', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + visibleText: 'Home text', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + visibleText: 'Docs text', + }, + ], + [ + { + jsonrpc: '2.0', + id: 811, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 812, + method: 'tools/call', + params: { name: 'browser_get_visible_text', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 812)); + expect(text).toContain('Docs text'); + }); + + it('opens a page and makes it selected', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 821, + method: 'tools/call', + params: { + name: 'browser_open_page', + arguments: { url: 'https://example.com/new' }, + }, + }, + { + jsonrpc: '2.0', + id: 822, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const openText = getResponseText(responses.find((message) => message.id === 821)); + expect(openText).toContain('status: opened'); + expect(openText).toContain('url: https://example.com/new'); + + const listText = getResponseText(responses.find((message) => message.id === 822)); + expect(listText).toContain('https://example.com/new'); + expect(listText).toContain('selected: true'); + }); + + it('closes the selected page and falls back deterministically', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 831, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 832, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 833, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const closeText = getResponseText(responses.find((message) => message.id === 832)); + expect(closeText).toContain('status: closed'); + + const listText = getResponseText(responses.find((message) => message.id === 833)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + expect(listText).not.toContain('Docs'); + }); + + it('keeps the selected page when closing a different page', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + visibleText: 'Home text', + }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + visibleText: 'Docs text', + }, + { + id: 'page-3', + title: 'Pricing', + currentUrl: 'https://example.com/pricing', + visibleText: 'Pricing text', + }, + ], + [ + { + jsonrpc: '2.0', + id: 834, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, + }, + { + jsonrpc: '2.0', + id: 835, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-3' } }, + }, + { + jsonrpc: '2.0', + id: 836, + method: 'tools/call', + params: { name: 'browser_get_visible_text', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 837, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const visibleText = getResponseText(responses.find((message) => message.id === 836)); + expect(visibleText).toContain('Home text'); + + const listText = getResponseText(responses.find((message) => message.id === 837)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + expect(listText).not.toContain('Pricing'); + }); + + it('reconciles a stale selected page when browser_get_session_info is called', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 841, + method: 'tools/call', + params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, + }, + { + jsonrpc: '2.0', + id: 842, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 843, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 843)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + }); + + it('closes a page by pageId', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 851, + method: 'tools/call', + params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, + }, + { + jsonrpc: '2.0', + id: 852, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 853, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const closeText = getResponseText(responses.find((message) => message.id === 852)); + expect(closeText).toContain('pageId: page-2'); + expect(closeText).toContain('status: closed'); + + const listText = getResponseText(responses.find((message) => message.id === 853)); + expect(listText).toContain('0. Home'); + expect(listText).not.toContain('https://example.com/new'); + }); + + it('adds an interception rule and lists it by bound pageId', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 901, + method: 'tools/call', + params: { + name: 'browser_select_page', + arguments: { pageIndex: 1 }, + }, + }, + { + jsonrpc: '2.0', + id: 902, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 903, + method: 'tools/call', + params: { + name: 'browser_list_intercept_rules', + arguments: {}, + }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 903)); + expect(listText).toContain('pageId: page-2'); + expect(listText).toContain('urlIncludes: /api'); + expect(listText).toContain('method: GET'); + expect(listText).toContain('action: continue'); + }); + + it('keeps an existing rule bound to the original page after selected page changes', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 911, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, + }, + { + jsonrpc: '2.0', + id: 912, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'POST', action: 'fail' }, + }, + }, + { + jsonrpc: '2.0', + id: 913, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 914, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 914)); + expect(listText).toContain('pageId: page-1'); + expect(listText).toContain('method: POST'); + expect(listText).toContain('action: fail'); + }); + + it('keeps richer matching rules bound to the original page after selected page changes', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 1003, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 1004, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 7, + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 1005, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, + }, + { + jsonrpc: '2.0', + id: 1006, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 1006)); + expect(listText).toContain('pageId: page-2'); + expect(listText).toContain('resourceType: XHR'); + expect(listText).toContain('priority: 7'); + }); + + it('removes an interception rule by ruleId', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 921, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 922, + method: 'tools/call', + params: { + name: 'browser_remove_intercept_rule', + arguments: { ruleId: 'rule-1' }, + }, + }, + { + jsonrpc: '2.0', + id: 923, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const removeText = getResponseText(responses.find((message) => message.id === 922)); + expect(removeText).toContain('ruleId: rule-1'); + expect(removeText).toContain('status: removed'); + + const listText = getResponseText(responses.find((message) => message.id === 923)); + expect(listText).toBe('status: empty'); + }); + + it('records recent requests with matched rule action summaries', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-1', + url: 'https://example.com/api/users', + method: 'GET', + resourceType: 'XHR', + }, + { + requestId: 'req-2', + url: 'https://example.com/assets/app.js', + method: 'GET', + resourceType: 'Script', + }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 931, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'fail' }, + }, + }, + { + jsonrpc: '2.0', + id: 932, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 932)); + expect(listText).toContain('requestId: req-1'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(listText).toContain('action: fail'); + expect(listText).toContain('requestId: req-2'); + expect(listText).toContain('action: continue'); + }); + + it('removes rules and recent requests bound to a page after that page is closed', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + intercept: { + pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 941, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 942, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 943, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 944, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 945, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 946, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const preCloseRequestsText = getResponseText(responses.find((message) => message.id === 943)); + expect(preCloseRequestsText).toContain('requestId: req-closed'); + + const listRulesText = getResponseText(responses.find((message) => message.id === 945)); + expect(listRulesText).not.toContain('pageId: page-2'); + + const postCloseRequestsText = getResponseText(responses.find((message) => message.id === 946)); + expect(postCloseRequestsText).not.toContain('requestId: req-closed'); + expect(postCloseRequestsText).not.toContain('pageId: page-2'); + }); + + it('rejects browser_add_intercept_rule when pageIndex and pageId are both provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 951, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + pageIndex: 0, + pageId: 'page-1', + urlIncludes: '/api', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 951); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together'); + }); + + it('rejects browser_add_intercept_rule when action is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 952, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + action: 'mock', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 952); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); + }); + + it('rejects intercept rules when urlPattern and urlRegex are both provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 981, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlPattern: 'https://example.com/api/*', + urlRegex: '^https://example\\.com/api/', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 981); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: urlPattern and urlRegex cannot be used together'); + }); + + it('rejects intercept rules when priority is not an integer', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 982, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + priority: 1.5, + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 982); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: priority must be an integer'); + }); + + it('rejects intercept rules when headerMatchers is not an array', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 983, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: 'x', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 983); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers must be an array'); + }); + + it('rejects intercept rules when a header matcher is missing name', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 984, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ valueIncludes: 'staging' }], + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 984); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.name is required'); + }); + + it('rejects intercept rules when a header matcher has no value matcher', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 985, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'x-env' }], + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 985); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex'); + }); + + it('rejects intercept rules when no matching condition is provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 986, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + priority: 10, + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 986); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: at least one matching condition is required'); + }); + + it('adds a resourceType interception rule and lists its richer matching summary', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 987, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 10, + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 988, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 988)); + expect(listText).toContain('resourceType: XHR'); + expect(listText).toContain('priority: 10'); + }); + + it('prefers the higher-priority matched rule over an earlier lower-priority rule', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-priority', + url: 'https://example.com/api/orders', + method: 'GET', + resourceType: 'XHR', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 989, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + action: 'fulfill', + statusCode: 201, + body: 'low', + }, + }, + }, + { + jsonrpc: '2.0', + id: 990, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 10, + action: 'fulfill', + statusCode: 202, + body: 'high', + }, + }, + }, + { + jsonrpc: '2.0', + id: 991, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 991)); + expect(listText).toContain('requestId: req-priority'); + expect(listText).toContain('matchedRuleId: rule-2'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(202); + }); + + it('keeps creation order when matched rules have the same priority', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-same-priority', + url: 'https://example.com/api/orders', + method: 'GET', + resourceType: 'XHR', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 9911, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + priority: 5, + action: 'fulfill', + statusCode: 201, + body: 'first', + }, + }, + }, + { + jsonrpc: '2.0', + id: 9912, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + resourceType: 'XHR', + priority: 5, + action: 'fulfill', + statusCode: 202, + body: 'second', + }, + }, + }, + { + jsonrpc: '2.0', + id: 9913, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 9913)); + expect(listText).toContain('requestId: req-same-priority'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(201); + }); + + it('matches urlPattern rules with wildcard syntax', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-pattern', + url: 'https://example.com/api/v1/users', + method: 'GET', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 992, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlPattern: 'https://example.com/api/*', + action: 'fail', + }, + }, + }, + { + jsonrpc: '2.0', + id: 993, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 993)); + expect(listText).toContain('requestId: req-pattern'); + expect(listText).toContain('action: fail'); + expect(pages[0]?.intercept?.failedRequests?.[0]?.requestId).toBe('req-pattern'); + }); + + it('matches urlRegex rules', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-regex', + url: 'https://example.com/api/users', + method: 'GET', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 994, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlRegex: '^https://example\\.com/api/(users|teams)$', + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 995, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 995)); + expect(listText).toContain('requestId: req-regex'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-regex'); + }); + + it('matches headerMatchers using valueIncludes and case-insensitive header names', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-header-includes', + url: 'https://example.com/api/header-includes', + method: 'GET', + requestHeaders: { + 'X-Env': 'staging-us', + }, + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 996, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'x-env', valueIncludes: 'staging' }], + action: 'fulfill', + statusCode: 207, + body: 'matched-includes', + }, + }, + }, + { + jsonrpc: '2.0', + id: 997, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 997)); + expect(listText).toContain('requestId: req-header-includes'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(207); + }); + + it('matches headerMatchers using valueRegex', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-header-regex', + url: 'https://example.com/api/header-regex', + method: 'GET', + requestHeaders: { + 'x-tenant': 'acme-prod', + }, + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 998, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'X-Tenant', valueRegex: '^acme-' }], + action: 'continue', + }, + }, + }, + { + jsonrpc: '2.0', + id: 999, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 999)); + expect(listText).toContain('requestId: req-header-regex'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-header-regex'); + }); + + it('adds a fulfill interception rule and lists its response summary', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 961, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + method: 'GET', + action: 'fulfill', + statusCode: 202, + contentType: 'application/json', + body: '{"ok":true}', + }, + }, + }, + { + jsonrpc: '2.0', + id: 962, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ] + ); + + const listText = getResponseText(responses.find((message) => message.id === 962)); + expect(listText).toContain('action: fulfill'); + expect(listText).toContain('statusCode: 202'); + expect(listText).toContain('contentType: application/json'); + }); + + it('fulfills a paused request with the configured mock response', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-fulfill-1', + url: 'https://example.com/api/mock/users', + method: 'GET', + resourceType: 'XHR', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 963, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + method: 'GET', + action: 'fulfill', + statusCode: 200, + contentType: 'application/json', + body: '{"users":[1]}', + }, + }, + }, + { + jsonrpc: '2.0', + id: 964, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 964)); + expect(listText).toContain('requestId: req-fulfill-1'); + expect(listText).toContain('matchedRuleId: rule-1'); + expect(listText).toContain('action: fulfill'); + expect(pages[0]?.intercept?.fulfilledRequests).toEqual([ + expect.objectContaining({ + requestId: 'req-fulfill-1', + responseCode: 200, + }), + ]); + }); + + it('passes custom response headers to Fetch.fulfillRequest', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { + requestId: 'req-fulfill-2', + url: 'https://example.com/api/mock/headers', + method: 'GET', + }, + ], + }, + }, + ]; + const responses = await runMcpRequests( + pages, + [ + { + jsonrpc: '2.0', + id: 965, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock/headers', + action: 'fulfill', + statusCode: 201, + responseHeaders: [ + { name: 'Cache-Control', value: 'no-store' }, + { name: 'X-Mocked-By', value: 'ccs-browser' }, + ], + body: 'ok', + }, + }, + }, + { + jsonrpc: '2.0', + id: 966, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 966)); + expect(listText).toContain('action: fulfill'); + expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseHeaders).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Cache-Control', value: 'no-store' }), + expect.objectContaining({ name: 'X-Mocked-By', value: 'ccs-browser' }), + ]) + ); + }); + + it('allows fulfill rules with an empty response body', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 967, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/empty', + action: 'fulfill', + statusCode: 204, + contentType: 'text/plain', + body: '', + }, + }, + }, + ] + ); + + const addText = getResponseText(responses.find((message) => message.id === 967)); + expect(addText).toContain('action: fulfill'); + expect(addText).toContain('statusCode: 204'); + }); + + it('rejects fulfill rules when statusCode is out of range', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 968, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + statusCode: 99, + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 968); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); + }); + + it('rejects fulfill rules when responseHeaders is not an array', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 969, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + responseHeaders: 'x', + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 969); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array'); + }); + + it('rejects fulfill rules when a responseHeaders entry is missing name', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 970, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + responseHeaders: [{ value: 'x' }], + body: 'x', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 970); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required'); + }); + + it('rejects fulfill rules when body is not a string', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 971, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock', + action: 'fulfill', + body: 123, + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 971); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: body must be a string'); + }); + + it('rejects intercept rules when urlRegex is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1001, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlRegex: '[', + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 1001); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: urlRegex must be a valid regular expression'); + }); + + it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 1002, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + headerMatchers: [{ name: 'x-env', valueRegex: '[' }], + action: 'continue', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 1002); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression'); + }); + + it('removes fulfill rules and request summaries after the bound page is closed', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { + id: 'page-2', + title: 'Mocked', + currentUrl: 'https://example.com/mocked', + intercept: { + pausedRequests: [ + { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, + ], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } }, + { + jsonrpc: '2.0', + id: 973, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api/mock/close', + action: 'fulfill', + statusCode: 200, + body: 'done', + }, + }, + }, + { jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + { jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } }, + { jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } }, + { jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, + ], + { + responseTimeoutMs: 12000, + } + ); + + expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close'); + expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2'); + expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close'); + }); + + it('returns only the requested number of recent requests', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, + { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 953, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 954, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: { limit: 1 } }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 954)); + expect(listText).toContain('requestId: req-2'); + expect(listText).not.toContain('requestId: req-1'); + }); + + it('fails browser_add_intercept_rule when Fetch.enable fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + enableError: 'Fetch.enable blocked', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 955, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 956, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const addResponse = responses.find((message) => message.id === 955); + expect((addResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(addResponse)).toContain('Browser MCP failed: Fetch.enable blocked'); + + const listText = getResponseText(responses.find((message) => message.id === 956)); + expect(listText).toBe('status: empty'); + }); + +}); diff --git a/tests/unit/hooks/browser-mcp-test-harness.ts b/tests/unit/hooks/browser-mcp-test-harness.ts new file mode 100644 index 00000000..fd78c81f --- /dev/null +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -0,0 +1,1861 @@ +// Shared harness extracted from the Browser MCP hook suite. +// Keep helpers here and split domain assertions into smaller *.test.ts files. + +import { spawn } from 'child_process'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import * as http from 'node:http'; +import { WebSocketServer } from 'ws'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type JsonRpcMessage = Record; + +type MockRect = { + x: number; + y: number; + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; +}; + +type MockQueryState = { + exists?: boolean; + connected?: boolean; + innerText?: string; + textContent?: string; + rect?: MockRect; + display?: string; + visibility?: string; + opacity?: string; + href?: string; + onclick?: string; + error?: string; +}; + +type MockQueryPlan = MockQueryState | MockQueryState[]; + +type MockClickState = { + error?: string; + disabled?: boolean; + detached?: boolean; + hidden?: boolean; + requireMouseSequence?: boolean; + requireNativeClick?: boolean; + forbidSyntheticClickEvent?: boolean; + cancelMouseDown?: boolean; + cancelMouseUp?: boolean; + detachAfterMouseDown?: boolean; + mouseSequenceError?: string; + label?: string; + expectedOffset?: { x: number; y: number }; + expectedButton?: 'left' | 'middle' | 'right'; + expectedClickCount?: number; + requireDoubleClickEvent?: boolean; +}; + +type MockClickPlan = MockClickState | MockClickState[]; + +type MockHoverState = { + error?: string; + detached?: boolean; + hidden?: boolean; + zeroSized?: boolean; + requireCdpMouseMove?: boolean; + lastMouseMove?: { + x: number; + y: number; + }; +}; + +type MockWaitPlan = { + selectorSnapshots?: Record; + pageTextSequence?: string[]; +}; + +type MockDownloadProgressState = { + receivedBytes: number; + totalBytes: number; + state: 'inProgress' | 'completed' | 'canceled'; + filePath?: string; +}; + +type MockDownloadState = { + guid?: string; + url: string; + suggestedFilename: string; + frameId?: string; + progress?: MockDownloadProgressState[]; +}; + +type MockBrowserState = { + setDownloadBehaviorCalls?: Array<{ + behavior: string; + downloadPath?: string; + eventsEnabled?: boolean; + }>; + canceledDownloadGuids?: string[]; +}; + +type MockPageEventPlan = { + dialogs?: Array<{ type: string; message: string }>; + navigations?: Array<{ url: string; parentId?: string }>; + requests?: Array<{ url: string; method: string }>; + downloads?: MockDownloadState[]; +}; + +type MockFileInputState = { + kind: 'file' | 'nonfile'; + multiple?: boolean; + assignedFiles?: string[]; +}; + +type MockFileInputPlan = MockFileInputState | MockFileInputState[]; + +type MockDropzoneState = { + accepted?: boolean; + acceptedByCancel?: boolean; + requireFiles?: boolean; + receivedEventTypes?: string[]; + receivedFiles?: Array<{ name: string; size: number; type: string }>; + error?: string; +}; + +type MockDropzonePlan = MockDropzoneState | MockDropzoneState[]; + +type MockPointerActionRecord = { + type: string; + x?: number; + y?: number; + button?: string; +}; + +type MockDragPlan = { + recordedActions?: MockPointerActionRecord[]; +}; + +type MockRecordedEvent = + | { + kind: 'click'; + selector: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + button?: 'left' | 'middle' | 'right'; + clickCount?: number; + offsetX?: number; + offsetY?: number; + timestamp?: number; + } + | { + kind: 'type'; + selector: string; + text: string; + nth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'press_key'; + key: string; + modifiers?: string[]; + timestamp?: number; + } + | { + kind: 'scroll'; + selector?: string; + deltaX?: number; + deltaY?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'drag_element'; + selector: string; + targetSelector?: string; + targetX?: number; + targetY?: number; + nth?: number; + targetNth?: number; + frameSelector?: string; + pierceShadow?: boolean; + timestamp?: number; + } + | { + kind: 'pointer_action'; + actions: Array<{ + type: 'move' | 'down' | 'up' | 'pause'; + selector?: string; + x?: number; + y?: number; + button?: 'left' | 'middle' | 'right'; + durationMs?: number; + }>; + timestamp?: number; + }; + +type MockRecordingWarning = { + message: string; +}; + +type MockRecordingPlan = { + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + injectionError?: string; + finalizeError?: string; +}; + +type MockFrameState = { + selector: string; + query?: Record; + visibleText?: string; + fileInputs?: Record; + dropzones?: Record; +}; + +type MockShadowRootState = { + hostSelector: string; + query?: Record; + fileInputs?: Record; + dropzones?: Record; +}; + +type MockEvalPlan = Record< + string, + { + result?: unknown; + error?: string; + nonSerializable?: boolean; + unserializableValue?: string; + } +>; + +type MockInterceptRuleMatch = { + url: string; + method: string; + resourceType?: string; + requestId?: string; + requestHeaders?: Record; +}; + +type MockFulfilledRequest = { + requestId: string; + responseCode?: number; + responseHeaders?: Array<{ name: string; value: string }>; + body?: string; +}; + +type MockInterceptState = { + pausedRequests?: MockInterceptRuleMatch[]; + continuedRequestIds?: string[]; + failedRequests?: Array<{ requestId: string; errorReason?: string }>; + fulfilledRequests?: MockFulfilledRequest[]; + fetchEnabledPatterns?: unknown[]; + enableError?: string; + pauseDispatchDelayMs?: number; +}; + +type MockFrameTree = { + frame: { id: string }; + childFrames?: MockFrameTree[]; +}; + +type MockPageState = { + id: string; + title: string; + currentUrl: string; + fileInputs?: Record; + browser?: MockBrowserState; + frameTree?: MockFrameTree; + frameTreeSequence?: MockFrameTree[]; + readyStateSequence?: string[]; + visibleText?: string; + domSnapshot?: string; + navigate?: Record; + click?: Record; + hover?: Record; + query?: Record; + wait?: MockWaitPlan; + eval?: MockEvalPlan; + frames?: MockFrameState[]; + shadowRoots?: MockShadowRootState[]; + dropzones?: Record; + drag?: MockDragPlan; + recording?: MockRecordingPlan; + events?: MockPageEventPlan; + intercept?: MockInterceptState; + screenshot?: { + expectedClip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; + requireScrolledMeasurement?: boolean; + scrolledSelectors?: string[]; + data?: string; + lastCaptureBeyondViewport?: boolean; + lastClip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; + }; + type?: Record< + string, + { + kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; + inputType?: string; + value?: string; + expectedValueWhenClearFirst?: string; + expectedValueWhenAppend?: string; + requireFocus?: boolean; + focused?: boolean; + } + >; + keyboard?: { + expectedKey?: string; + expectedModifiers?: string[]; + expectedRepeat?: number; + _seenKeyDownCount?: number; + }; + scroll?: Record< + string, + { + expectedBehavior?: 'into-view' | 'by-offset'; + expectedDeltaX?: number; + expectedDeltaY?: number; + } + >; +}; + +const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); + +type RunMcpRequestsOptions = { + serverPath?: string; + childEnv?: NodeJS.ProcessEnv; + responseTimeoutMs?: number; +}; + +function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number, + timeoutMs = 7000 +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + let stderrBuffer = ''; + let settled = false; + const responses: Array> = []; + const fail = (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }; + const finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(responses); + }; + const timer = setTimeout(() => { + const details = stderrBuffer.trim(); + fail( + new Error( + details + ? `Timed out waiting for MCP responses\n${details}` + : 'Timed out waiting for MCP responses' + ) + ); + }, timeoutMs); + + function tryParse(): void { + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.subarray(newlineIndex + 1); + if (!body) { + continue; + } + + responses.push(JSON.parse(body) as Record); + if (responses.length >= expectedCount) { + finish(); + return; + } + } + } + + if (!child.stdout) { + fail(new Error('MCP child stdout is unavailable')); + return; + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + + child.stderr?.on('data', (chunk: Buffer | string) => { + stderrBuffer += chunk.toString(); + }); + + child.on('error', (error) => { + fail(error); + }); + + child.on('exit', (code, signal) => { + if (settled || responses.length >= expectedCount) { + return; + } + const details = stderrBuffer.trim(); + const suffix = details ? `\n${details}` : ''; + fail( + new Error( + `MCP child exited before all responses arrived (code=${code}, signal=${signal})${suffix}` + ) + ); + }); + }); +} + +function getResponseText(message: Record | undefined): string { + const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; + return result.content?.[0]?.text || ''; +} + +function createReplayStep(step: Record): Record { + return step; +} + +function createOrchestrationBlock(block: Record): Record { + return block; +} + +function parseJsonArgument(expression: string, key: string): string | undefined { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return undefined; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return undefined; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + if (!decoded.startsWith('"') && !decoded.startsWith("'")) { + return undefined; + } + return JSON.parse(decoded) as string; + } + index += 1; + } + + return undefined; +} + +function parseNumberArgument(expression: string, key: string): number | undefined { + const match = expression.match(new RegExp(`const ${key} = ([0-9]+|undefined);`)); + if (!match?.[1] || match[1] === 'undefined') { + return undefined; + } + return Number.parseInt(match[1], 10); +} + +function parseParsedJsonArrayArgument(expression: string, key: string): T[] { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return []; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return []; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + return JSON.parse(decoded) as T[]; + } + index += 1; + } + + return []; +} + +function parseParsedJsonObjectArgument(expression: string, key: string): T | null { + const marker = `const ${key} = JSON.parse(`; + const start = expression.indexOf(marker); + if (start === -1) { + return null; + } + + const quoteStart = start + marker.length; + const quote = expression[quoteStart]; + if (quote !== '"' && quote !== "'") { + return null; + } + + let index = quoteStart + 1; + while (index < expression.length) { + if (expression[index] === '\\') { + index += 2; + continue; + } + if (expression[index] === quote) { + const encoded = expression.slice(quoteStart, index + 1); + const decoded = JSON.parse(encoded) as string; + return JSON.parse(decoded) as T; + } + index += 1; + } + + return null; +} + +function pickMockMatch( + plan: T | T[] | undefined, + nth = 0 +): { + count: number; + target: T | undefined; +} { + if (Array.isArray(plan)) { + return { count: plan.length, target: plan[nth] }; + } + return { count: plan ? 1 : 0, target: nth === 0 ? plan : undefined }; +} + +function shiftSelectorSnapshot(page: MockPageState, selector: string): MockQueryPlan | undefined { + const queue = page.wait?.selectorSnapshots?.[selector]; + if (!queue || queue.length === 0) { + return page.query?.[selector]; + } + if (queue.length === 1) { + return queue[0]; + } + return queue.shift(); +} + +function getMockFrame(page: MockPageState, frameSelector: string): MockFrameState | undefined { + return page.frames?.find((frame) => frame.selector === frameSelector); +} + +function getMockShadowRoot(page: MockPageState): MockShadowRootState | undefined { + return page.shadowRoots?.[0]; +} + +function getMockDropzoneState( + page: Pick, + selector: string, + options: { nth?: number; frameSelector?: string; pierceShadow?: boolean } = {} +): MockDropzoneState | null { + const nth = options.nth ?? 0; + if (options.frameSelector) { + const frame = page.frames?.find((entry) => entry.selector === options.frameSelector); + const plan = frame?.dropzones?.[selector]; + if (!plan) { + return null; + } + return Array.isArray(plan) ? (plan[nth] ?? null) : plan; + } + if (options.pierceShadow) { + for (const shadowRoot of page.shadowRoots || []) { + const plan = shadowRoot.dropzones?.[selector]; + if (plan) { + return Array.isArray(plan) ? (plan[nth] ?? null) : plan; + } + } + } + const plan = page.dropzones?.[selector]; + if (!plan) { + return null; + } + return Array.isArray(plan) ? (plan[nth] ?? null) : plan; +} + +function pushPointerAction(page: MockPageState, action: MockPointerActionRecord): void { + page.drag = page.drag || {}; + page.drag.recordedActions = page.drag.recordedActions || []; + page.drag.recordedActions.push(action); +} + +function getMockRecordingPlan(page: MockPageState): MockRecordingPlan { + page.recording = page.recording || {}; + return page.recording; +} + +function shiftPageText(page: MockPageState): string { + const queue = page.wait?.pageTextSequence; + if (!queue || queue.length === 0) { + return page.visibleText || ''; + } + if (queue.length === 1) { + return queue[0] || ''; + } + return queue.shift() || ''; +} + +function resolveNodeModulesPath(): string { + const candidates = [ + join(process.cwd(), 'node_modules'), + join(process.cwd(), '..', 'node_modules'), + join(process.cwd(), '..', '..', 'node_modules'), + ]; + return ( + candidates.find((candidate) => existsSync(join(candidate, 'ws'))) || + candidates.find((candidate) => existsSync(candidate)) || + candidates[0] + ); +} + +function createMockBrowser(pagesInput: MockPageState[]) { + let tempDir = ''; + let httpServer: http.Server | null = null; + let wsServer: WebSocketServer | null = null; + let browserSocketPath = ''; + const pageStates = new Map(); + let nextPageCounter = pagesInput.length + 1; + + for (const [index, page] of pagesInput.entries()) { + if (page.visibleText === undefined) { + page.visibleText = 'Hello from visible text'; + } + if (page.domSnapshot === undefined) { + page.domSnapshot = 'Hello from DOM snapshot'; + } + pageStates.set(`/devtools/page/${index + 1}`, page); + } + + async function start(options: RunMcpRequestsOptions = {}) { + const entryServerPath = options.serverPath || bundledServerPath; + const childEnv = options.childEnv || {}; + tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); + + const port = await new Promise((resolve, reject) => { + httpServer = http.createServer((req, res) => { + const address = httpServer?.address(); + const serverPort = address && typeof address !== 'string' ? address.port : 0; + + if (req.url === '/json/list') { + const pageEntries = Array.from(pageStates.entries()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify([ + ...pageEntries.map(([wsPath, page]) => ({ + id: page.id, + type: 'page', + title: page.title, + url: page.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + })), + { + id: 'browser-target', + type: 'browser', + title: 'Browser', + url: '', + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${browserSocketPath || '/devtools/browser'}`, + }, + ]) + ); + return; + } + + if (req.url?.startsWith('/json/new')) { + const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`); + const requestedUrl = parsed.searchParams.get('url') || 'about:blank'; + const wsPath = `/devtools/page/${nextPageCounter}`; + const newPage: MockPageState = { + id: `page-${nextPageCounter}`, + title: requestedUrl === 'about:blank' ? 'about:blank' : requestedUrl, + currentUrl: requestedUrl, + visibleText: 'New page visible text', + domSnapshot: 'New page', + }; + pageStates.set(wsPath, newPage); + nextPageCounter += 1; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: newPage.id, + type: 'page', + title: newPage.title, + url: newPage.currentUrl, + webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, + }) + ); + return; + } + + if (req.url?.startsWith('/json/close/')) { + const targetId = decodeURIComponent(req.url.slice('/json/close/'.length)); + const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId); + if (!entry) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'page not found' })); + return; + } + pageStates.delete(entry[0]); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: targetId })); + return; + } + + res.writeHead(404); + res.end('not found'); + }); + + httpServer.once('error', reject); + httpServer.listen(0, '127.0.0.1', () => { + const address = httpServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock browser server port')); + return; + } + resolve(address.port); + }); + }); + + browserSocketPath = '/devtools/browser'; + wsServer = new WebSocketServer({ server: httpServer as http.Server }); + wsServer.on('connection', (socket, request) => { + if ((request.url || '') === browserSocketPath) { + const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + if (message.method === 'Browser.setDownloadBehavior') { + browserState.setDownloadBehaviorCalls = browserState.setDownloadBehaviorCalls || []; + browserState.setDownloadBehaviorCalls.push({ + behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', + downloadPath: + typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, + eventsEnabled: + typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, + }); + reply({}); + return; + } + + if (message.method === 'Browser.cancelDownload') { + browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || []; + browserState.canceledDownloadGuids.push(String(message.params?.guid || '')); + reply({}); + return; + } + }); + + for (const page of pagesInput) { + for (const [index, download] of (page.events?.downloads || []).entries()) { + const guid = download.guid || `${page.id}-download-${index + 1}`; + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadWillBegin', + params: { + frameId: download.frameId || `frame-${page.id}`, + guid, + url: download.url, + suggestedFilename: download.suggestedFilename, + }, + }) + ); + }, 10 + index * 40); + + for (const [progressIndex, progress] of (download.progress || []).entries()) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Browser.downloadProgress', + params: { + guid, + totalBytes: progress.totalBytes, + receivedBytes: progress.receivedBytes, + state: progress.state, + filePath: progress.filePath, + }, + }) + ); + }, 20 + index * 40 + progressIndex * 20); + } + } + } + return; + } + + const page = pageStates.get(request.url || ''); + if (!page) { + socket.close(); + return; + } + + const remoteObjects = new Map(); + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) as { + id: number; + method: string; + params?: Record; + }; + + function reply(result: unknown): void { + socket.send(JSON.stringify({ id: message.id, result })); + } + + function replyError(errorText: string): void { + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { subtype: 'error', description: errorText } }, + }) + ); + } + + if (message.method === 'Page.navigate') { + const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; + const navigatePlan = page.navigate?.[targetUrl]; + if (navigatePlan?.errorText) { + reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); + return; + } + if (navigatePlan) { + page.currentUrl = navigatePlan.finalUrl; + page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; + } + reply({ frameId: 'frame-1' }); + return; + } + + if (message.method === 'Page.getFrameTree') { + const nextFrameTree = page.frameTreeSequence?.shift(); + reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); + return; + } + + if (message.method === 'Page.captureScreenshot') { + if (!page.screenshot) { + reply({ data: '' }); + return; + } + page.screenshot.lastCaptureBeyondViewport = + message.params?.captureBeyondViewport === true; + const clip = + message.params?.clip && typeof message.params.clip === 'object' + ? (message.params.clip as Record) + : null; + page.screenshot.lastClip = clip + ? { + x: Number(clip.x), + y: Number(clip.y), + width: Number(clip.width), + height: Number(clip.height), + scale: Number(clip.scale), + } + : undefined; + if (page.screenshot.expectedClip) { + expect(page.screenshot.lastClip).toEqual(page.screenshot.expectedClip); + } + reply({ data: page.screenshot.data || '' }); + return; + } + + if (message.method === 'Input.dispatchMouseEvent') { + const type = typeof message.params?.type === 'string' ? message.params.type : ''; + const x = Number(message.params?.x); + const y = Number(message.params?.y); + const button = typeof message.params?.button === 'string' ? message.params.button : undefined; + for (const hoverPlan of Object.values(page.hover || {})) { + if (type === 'mouseMoved') { + hoverPlan.lastMouseMove = { x, y }; + } + } + pushPointerAction(page, { type, x, y, button }); + reply({}); + return; + } + + if (message.method === 'Input.dispatchKeyEvent') { + const type = typeof message.params?.type === 'string' ? message.params.type : ''; + const key = typeof message.params?.key === 'string' ? message.params.key : ''; + const modifiersMask = Number(message.params?.modifiers || 0); + const modifiers = [ + ...(modifiersMask & 1 ? ['Alt'] : []), + ...(modifiersMask & 2 ? ['Control'] : []), + ...(modifiersMask & 4 ? ['Meta'] : []), + ...(modifiersMask & 8 ? ['Shift'] : []), + ]; + const keyboardPlan = page.keyboard; + if (type === 'keyDown') { + if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { + replyError(`unexpected key: ${key}`); + return; + } + if ( + keyboardPlan?.expectedModifiers && + JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) + ) { + replyError(`unexpected modifiers for key: ${key}`); + return; + } + if (keyboardPlan) { + keyboardPlan._seenKeyDownCount = (keyboardPlan._seenKeyDownCount || 0) + 1; + } + } + if ( + type === 'keyUp' && + typeof keyboardPlan?.expectedRepeat === 'number' && + (keyboardPlan._seenKeyDownCount || 0) !== keyboardPlan.expectedRepeat + ) { + replyError(`unexpected repeat for key: ${key}`); + return; + } + reply({}); + return; + } + + if (message.method === 'Page.enable' || message.method === 'Network.enable') { + reply({}); + if (message.method === 'Page.enable') { + const dialog = page.events?.dialogs?.[0]; + const navigations = page.events?.navigations || []; + if (dialog) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Page.javascriptDialogOpening', + params: { message: dialog.message, type: dialog.type }, + }) + ); + }, 10); + } + navigations.forEach((navigation, index) => { + setTimeout( + () => { + socket.send( + JSON.stringify({ + method: 'Page.frameNavigated', + params: { frame: { url: navigation.url, parentId: navigation.parentId } }, + }) + ); + }, + 10 + index * 10 + ); + }); + } + if (message.method === 'Network.enable') { + const requestPlan = page.events?.requests?.[0]; + if (requestPlan) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Network.requestWillBeSent', + params: { request: { url: requestPlan.url, method: requestPlan.method } }, + }) + ); + }, 10); + } + } + return; + } + + if (message.method === 'Fetch.enable') { + page.intercept = page.intercept || {}; + page.intercept.fetchEnabledPatterns = Array.isArray(message.params?.patterns) + ? (message.params?.patterns as unknown[]) + : []; + if (page.intercept.enableError) { + socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })); + return; + } + reply({}); + const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10; + for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) { + setTimeout(() => { + socket.send( + JSON.stringify({ + method: 'Fetch.requestPaused', + params: { + requestId: paused.requestId || `fetch-${index + 1}`, + resourceType: paused.resourceType || 'XHR', + request: { + url: paused.url, + method: paused.method, + headers: paused.requestHeaders || {}, + }, + }, + }) + ); + }, pauseDispatchDelayMs + index * 10); + } + return; + } + + if (message.method === 'Fetch.continueRequest') { + page.intercept = page.intercept || {}; + page.intercept.continuedRequestIds = page.intercept.continuedRequestIds || []; + page.intercept.continuedRequestIds.push(String(message.params?.requestId || '')); + reply({}); + return; + } + + if (message.method === 'Fetch.failRequest') { + page.intercept = page.intercept || {}; + page.intercept.failedRequests = page.intercept.failedRequests || []; + page.intercept.failedRequests.push({ + requestId: String(message.params?.requestId || ''), + errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', + }); + reply({}); + return; + } + + if (message.method === 'Fetch.fulfillRequest') { + page.intercept = page.intercept || {}; + page.intercept.fulfilledRequests = page.intercept.fulfilledRequests || []; + page.intercept.fulfilledRequests.push({ + requestId: String(message.params?.requestId || ''), + responseCode: + typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined, + responseHeaders: Array.isArray(message.params?.responseHeaders) + ? (message.params.responseHeaders as Array<{ name: string; value: string }>) + : [], + body: typeof message.params?.body === 'string' ? message.params.body : '', + }); + reply({}); + return; + } + + if (message.method === 'DOM.setFileInputFiles') { + const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : ''; + const target = remoteObjects.get(objectId); + if (!target) { + socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } })); + return; + } + target.assignedFiles = Array.isArray(message.params?.files) + ? (message.params.files as unknown[]).map((entry) => String(entry)) + : []; + reply({}); + return; + } + + if (message.method !== 'Runtime.evaluate') { + return; + } + + const expression = String(message.params?.expression || ''); + const recordingPayload = parseParsedJsonObjectArgument<{ + events?: MockRecordedEvent[]; + warnings?: MockRecordingWarning[]; + }>(expression, 'recordingPayload'); + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ =')) { + const plan = getMockRecordingPlan(page); + if (plan.injectionError) { + reply({ + result: { + type: 'object', + subtype: 'error', + description: plan.injectionError, + }, + }); + return; + } + + if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { + plan.events = recordingPayload.events || []; + plan.warnings = recordingPayload.warnings || []; + } + + reply({ result: { type: 'object', value: { installed: true } } }); + return; + } + + if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { + const plan = getMockRecordingPlan(page); + if (plan.finalizeError) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: plan.finalizeError } }, + }) + ); + return; + } + reply({ + result: { + type: 'object', + value: { + events: plan.events || [], + warnings: plan.warnings || [], + }, + }, + }); + return; + } + + if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + const filePayloads = parseParsedJsonArrayArgument<{ + name: string; + size: number; + mimeType: string; + }>(expression, 'filePayloads'); + const eventTypes = Array.from( + expression.matchAll(/new DragEvent\('(dragenter|dragover|drop)'/g) + ).map((match) => match[1]); + + const target = getMockDropzoneState(page, selector, { + nth, + frameSelector, + pierceShadow, + }); + if (!target) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (target.error) { + replyError(target.error); + return; + } + if (eventTypes.length === 0) { + replyError(`drag event sequence not found for selector: ${selector}`); + return; + } + if (target.requireFiles && filePayloads.length === 0) { + reply({ result: { type: 'object', value: { accepted: false } } }); + return; + } + target.receivedEventTypes = eventTypes; + target.receivedFiles = filePayloads.map((file) => ({ + name: file.name, + size: file.size, + type: file.mimeType, + })); + reply({ + result: { + type: 'object', + value: { + accepted: target.acceptedByCancel === true ? true : target.accepted !== false, + }, + }, + }); + return; + } + + if (page.eval?.[expression]) { + const evalPlan = page.eval[expression]; + if (evalPlan.error) { + socket.send( + JSON.stringify({ + id: message.id, + result: { exceptionDetails: { text: evalPlan.error } }, + }) + ); + return; + } + if (evalPlan.nonSerializable) { + socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); + return; + } + if (typeof evalPlan.unserializableValue === 'string') { + socket.send( + JSON.stringify({ + id: message.id, + result: { + result: { + type: 'number', + unserializableValue: evalPlan.unserializableValue, + }, + }, + }) + ); + return; + } + socket.send( + JSON.stringify({ + id: message.id, + result: { result: { type: 'object', value: evalPlan.result } }, + }) + ); + return; + } + + if (expression === '0') { + reply({ result: { type: 'number', value: 0 } }); + return; + } + + if (expression.includes('element is not a file input for selector:')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + + const fileInputPlan = frameSelector + ? getMockFrame(page, frameSelector)?.fileInputs?.[selector] + : pierceShadow + ? getMockShadowRoot(page)?.fileInputs?.[selector] + : page.fileInputs?.[selector]; + + const { count, target } = pickMockMatch(fileInputPlan, nth); + if (!target || count <= nth) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (target.kind !== 'file') { + replyError(`element is not a file input for selector: ${selector}`); + return; + } + + const objectId = `file-input:${page.id}:${selector}:${nth}:${frameSelector || 'root'}:${pierceShadow ? 'shadow' : 'light'}`; + remoteObjects.set(objectId, target); + socket.send( + JSON.stringify({ + id: message.id, + result: { + result: { + type: 'object', + subtype: 'node', + className: 'HTMLInputElement', + objectId, + }, + }, + }) + ); + return; + } + + if (expression.includes('document.title') && expression.includes('location.href')) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ title: page.title, url: page.currentUrl }), + }, + }); + return; + } + + if (expression.includes('document.body ? document.body.innerText')) { + reply({ result: { type: 'string', value: shiftPageText(page) } }); + return; + } + + if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { + reply({ result: { type: 'string', value: page.domSnapshot || '' } }); + return; + } + + if (expression.includes('document.readyState') && expression.includes('location.href')) { + const readyState = page.readyStateSequence?.shift() || 'complete'; + reply({ + result: { + type: 'string', + value: JSON.stringify({ href: page.currentUrl, readyState }), + }, + }); + return; + } + + if (expression.includes('scrollIntoView') && expression.includes('resolvedOffsetX')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth') ?? 0; + const clickPlan = page.click?.[selector]; + const { count, target: resolvedClickPlan } = pickMockMatch(clickPlan, nth); + const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); + const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); + const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; + const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); + const attemptedDoubleClickEvent = expression.includes("new MouseEvent('dblclick'"); + const readsDispatchResult = expression.includes('const dispatchResult = {'); + const gatesNativeClickOnDispatchResult = expression.includes( + 'if (!dispatchResult.shouldActivate)' + ); + const checksIsConnectedBeforeNativeClick = expression.includes( + 'if (!element.isConnected)' + ); + const catchIndex = expression.indexOf('catch (mouseError) {'); + const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); + const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( + (match) => match.index ?? -1 + ); + const attemptedFallbackClick = nativeClickIndexes.some( + (index) => + catchIndex !== -1 && + catchBlockEnd !== -1 && + index > catchIndex && + index < catchBlockEnd + ); + const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( + (index) => + catchIndex === -1 || + catchBlockEnd === -1 || + index < catchIndex || + index > catchBlockEnd + ); + const offsetX = parseNumberArgument(expression, 'offsetX'); + const offsetY = parseNumberArgument(expression, 'offsetY'); + const button = parseJsonArgument(expression, 'button') || 'left'; + const clickCount = parseNumberArgument(expression, 'clickCount') ?? 1; + if (!resolvedClickPlan) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); + return; + } + if (count <= nth) { + replyError(`element index ${nth} is out of range for selector: ${selector}`); + return; + } + if (resolvedClickPlan.detached && expression.includes('element.isConnected')) { + replyError(`element is detached for selector: ${selector}`); + return; + } + if (resolvedClickPlan.disabled) { + replyError(`element is disabled for selector: ${selector}`); + return; + } + if (resolvedClickPlan.hidden && expression.includes('getBoundingClientRect')) { + replyError(`element is hidden or not interactable for selector: ${selector}`); + return; + } + if (resolvedClickPlan.requireMouseSequence && !attemptedMouseSequence) { + replyError(`mousedown/mouseup required for selector: ${selector}`); + return; + } + if (resolvedClickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { + replyError(`synthetic click event forbidden for selector: ${selector}`); + return; + } + if ( + (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && + !readsDispatchResult + ) { + replyError(`dispatch result must be checked for selector: ${selector}`); + return; + } + if ( + (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && + !gatesNativeClickOnDispatchResult + ) { + replyError(`native click must be gated for selector: ${selector}`); + return; + } + if (resolvedClickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { + replyError(`connected state must be rechecked for selector: ${selector}`); + return; + } + if (resolvedClickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { + replyError(`native click required for selector: ${selector}`); + return; + } + if ( + resolvedClickPlan.expectedOffset && + (offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y) + ) { + replyError(`unexpected click offset for selector: ${selector}`); + return; + } + if (resolvedClickPlan.expectedButton && button !== resolvedClickPlan.expectedButton) { + replyError(`unexpected click button for selector: ${selector}`); + return; + } + if ( + typeof resolvedClickPlan.expectedClickCount === 'number' && + clickCount !== resolvedClickPlan.expectedClickCount + ) { + replyError(`unexpected click count for selector: ${selector}`); + return; + } + if ( + resolvedClickPlan.requireDoubleClickEvent && + clickCount === 2 && + !attemptedDoubleClickEvent + ) { + replyError(`dblclick event required for selector: ${selector}`); + return; + } + if (resolvedClickPlan.mouseSequenceError) { + if (!attemptedMouseSequence) { + replyError(`mousedown/mouseup required for selector: ${selector}`); + return; + } + if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ + resolvedOffsetX: offsetX ?? 50, + resolvedOffsetY: offsetY ?? 10, + button, + clickCount, + }), + }, + }); + return; + } + replyError(resolvedClickPlan.mouseSequenceError); + return; + } + if (resolvedClickPlan.error) { + replyError(resolvedClickPlan.error); + return; + } + reply({ + result: { + type: 'string', + value: JSON.stringify({ + resolvedOffsetX: offsetX ?? 50, + resolvedOffsetY: offsetY ?? 10, + button, + clickCount, + }), + }, + }); + return; + } + + if ( + expression.includes('getComputedStyle(element)') && + (expression.includes('boundingClientRect') || + expression.includes('visibleClip') || + expression.includes('centerPoint') || + expression.includes('querySelectorAll(selector)')) + ) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const nth = parseNumberArgument(expression, 'nth'); + const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; + const pierceShadow = expression.includes('const pierceShadow = true'); + const frame = frameSelector ? getMockFrame(page, frameSelector) : undefined; + const shadowRoot = pierceShadow ? getMockShadowRoot(page) : undefined; + const scopedQuery = frame?.query?.[selector] ?? shadowRoot?.query?.[selector]; + const frameRootPlan = frame?.query?.[frameSelector]; + const frameRect = !Array.isArray(frameRootPlan) ? frameRootPlan?.rect : undefined; + const applyFrameOffset = (rect?: MockRect): MockRect | undefined => { + if (!rect || !frameRect) { + return rect; + } + return { + x: rect.x + frameRect.left, + y: rect.y + frameRect.top, + width: rect.width, + height: rect.height, + top: rect.top + frameRect.top, + right: rect.right + frameRect.left, + bottom: rect.bottom + frameRect.top, + left: rect.left + frameRect.left, + }; + }; + const queryPlan = scopedQuery ?? shiftSelectorSnapshot(page, selector); + if ( + page.screenshot?.requireScrolledMeasurement && + (selector in (page.query || {}) || Boolean(scopedQuery)) && + !expression.includes('scrollIntoView') + ) { + replyError(`scrollIntoView required for selector: ${selector}`); + return; + } + if (Array.isArray(queryPlan) && expression.includes('querySelectorAll(selector)')) { + const targetIndex = nth ?? 0; + const target = queryPlan[targetIndex]; + if (target?.error) { + replyError(target.error); + return; + } + if (queryPlan.length <= targetIndex || target?.exists === false || !target) { + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: + nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, + count: queryPlan.length, + targetIndex, + targetMissing: true, + }), + }, + }); + return; + } + const rect = applyFrameOffset(target.rect); + const text = target.innerText || target.textContent || ''; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: true, + count: queryPlan.length, + targetIndex, + connected: target.connected !== false, + text, + innerText: target.innerText || '', + textContent: target.textContent || '', + boundingClientRect: rect, + display: target.display || 'block', + visibility: target.visibility || 'visible', + opacity: target.opacity || '1', + href: target.href || '', + onclick: target.onclick || '', + interactable: + target.connected !== false && + (target.display || 'block') !== 'none' && + (target.visibility || 'visible') !== 'hidden' && + Boolean(rect && rect.width > 0 && rect.height > 0), + }), + }, + }); + return; + } + const resolvedQueryPlan = Array.isArray(queryPlan) ? queryPlan.shift() : queryPlan; + if (resolvedQueryPlan?.error) { + replyError(resolvedQueryPlan.error); + return; + } + if (resolvedQueryPlan?.exists === false || !resolvedQueryPlan) { + reply({ result: { type: 'string', value: JSON.stringify({ exists: false }) } }); + return; + } + const rect = applyFrameOffset(resolvedQueryPlan.rect); + const text = resolvedQueryPlan.innerText || resolvedQueryPlan.textContent || ''; + const viewportWidth = 1280; + const viewportHeight = 720; + const clipX = Math.max(0, rect?.left ?? 0); + const clipY = Math.max(0, rect?.top ?? 0); + const clipRight = Math.min(viewportWidth, rect?.right ?? 0); + const clipBottom = Math.min(viewportHeight, rect?.bottom ?? 0); + reply({ + result: { + type: 'string', + value: JSON.stringify({ + exists: true, + connected: resolvedQueryPlan.connected !== false, + text, + innerText: resolvedQueryPlan.innerText || '', + textContent: resolvedQueryPlan.textContent || '', + boundingClientRect: rect, + display: resolvedQueryPlan.display || 'block', + visibility: resolvedQueryPlan.visibility || 'visible', + opacity: resolvedQueryPlan.opacity || '1', + interactable: + resolvedQueryPlan.connected !== false && + (resolvedQueryPlan.display || 'block') !== 'none' && + (resolvedQueryPlan.visibility || 'visible') !== 'hidden' && + Boolean(rect && rect.width > 0 && rect.height > 0), + centerPoint: rect + ? { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + } + : undefined, + visibleClip: rect + ? { + x: clipX, + y: clipY, + width: Math.max(0, clipRight - clipX), + height: Math.max(0, clipBottom - clipY), + scale: 1, + } + : undefined, + }), + }, + }); + return; + } + + if ( + expression.includes("dispatch('mouseover')") && + expression.includes("dispatch('mouseenter')") && + expression.includes("dispatch('mousemove')") + ) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const hoverPlan = page.hover?.[selector]; + if (!hoverPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (hoverPlan.detached) { + replyError(`element is detached for selector: ${selector}`); + return; + } + if (hoverPlan.hidden || hoverPlan.zeroSized) { + replyError(`element is hidden or not interactable for selector: ${selector}`); + return; + } + if (hoverPlan.requireCdpMouseMove && !hoverPlan.lastMouseMove) { + replyError(`real mouse movement required for selector: ${selector}`); + return; + } + if (hoverPlan.error) { + replyError(hoverPlan.error); + return; + } + reply({ result: { type: 'string', value: 'ok' } }); + return; + } + + if (expression.includes('focusTarget') && expression.includes('typedLength')) { + const selector = parseJsonArgument(expression, 'selector') || ''; + const text = parseJsonArgument(expression, 'text') ?? ''; + const clearFirst = expression.includes('const clearFirst = true'); + const typePlan = page.type?.[selector]; + if (!typePlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (typePlan.kind === 'unsupported') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.kind === 'noneditable') { + replyError(`element is not text-editable for selector: ${selector}`); + return; + } + if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { + replyError(`focus was not requested for selector: ${selector}`); + return; + } + + const currentValue = typePlan.value || ''; + const expectedValue = clearFirst + ? (typePlan.expectedValueWhenClearFirst ?? text) + : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); + + typePlan.focused = true; + typePlan.value = expectedValue; + reply({ + result: { + type: 'string', + value: JSON.stringify({ + value: expectedValue, + typedLength: expectedValue.length, + }), + }, + }); + return; + } + + if (expression.includes('new KeyboardEvent(')) { + const key = parseJsonArgument(expression, 'key') || ''; + const repeat = parseNumberArgument(expression, 'repeat') ?? 1; + const modifiersMatch = expression.match(/const modifiers = (\[[^\n;]*\]);/); + const modifiers = modifiersMatch ? (JSON.parse(modifiersMatch[1]) as string[]) : []; + const keyboardPlan = page.keyboard; + if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { + replyError(`unexpected key: ${key}`); + return; + } + if ( + keyboardPlan?.expectedModifiers && + JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) + ) { + replyError(`unexpected modifiers for key: ${key}`); + return; + } + if ( + typeof keyboardPlan?.expectedRepeat === 'number' && + repeat !== keyboardPlan.expectedRepeat + ) { + replyError(`unexpected repeat for key: ${key}`); + return; + } + reply({ + result: { + type: 'string', + value: JSON.stringify({ key, modifiers, repeat }), + }, + }); + return; + } + + if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) { + const selector = parseJsonArgument(expression, 'selector'); + const behavior = parseJsonArgument(expression, 'behavior') || ''; + const deltaX = Number(expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); + const deltaY = Number(expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); + const scrollPlan = selector ? page.scroll?.[selector] : undefined; + if (selector && !scrollPlan) { + replyError(`element not found for selector: ${selector}`); + return; + } + if (scrollPlan?.expectedBehavior && behavior !== scrollPlan.expectedBehavior) { + replyError(`unexpected scroll behavior for selector: ${selector}`); + return; + } + if ( + typeof scrollPlan?.expectedDeltaX === 'number' && + deltaX !== scrollPlan.expectedDeltaX + ) { + replyError(`unexpected deltaX for selector: ${selector}`); + return; + } + if ( + typeof scrollPlan?.expectedDeltaY === 'number' && + deltaY !== scrollPlan.expectedDeltaY + ) { + replyError(`unexpected deltaY for selector: ${selector}`); + return; + } + reply({ + result: { + type: 'string', + value: JSON.stringify( + selector + ? { scope: 'element', selector, behavior, deltaX, deltaY } + : { scope: 'page', behavior, deltaX, deltaY } + ), + }, + }); + return; + } + }); + }); + + const child = spawn('node', [entryServerPath], { + cwd: tempDir, + env: { + ...process.env, + ...childEnv, + CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return child; + } + + async function stop() { + await new Promise((resolve) => { + wsServer?.close(() => resolve()); + if (!wsServer) resolve(); + }); + wsServer = null; + + await new Promise((resolve) => { + httpServer?.close(() => resolve()); + if (!httpServer) resolve(); + }); + httpServer = null; + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + } + + return { start, stop }; +} + +async function runMcpRequests( + pages: MockPageState[], + requests: JsonRpcMessage[], + options: RunMcpRequestsOptions = {} +) { + const browser = createMockBrowser(pages); + const child = await browser.start(options); + + try { + const responsesPromise = collectResponses( + child, + requests.length + 1, + options.responseTimeoutMs + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + + for (const request of requests) { + child.stdin.write(encodeMessage(request)); + } + child.stdin.end(); + + return await responsesPromise; + } finally { + child.kill(); + await browser.stop(); + } +} + + +export { + bundledServerPath, + runMcpRequests, + getResponseText, + createReplayStep, + createOrchestrationBlock, + getMockDropzoneState, + getMockFrame, + getMockShadowRoot, + getMockRecordingPlan, + resolveNodeModulesPath, + cpSync, + rmSync, + mkdtempSync, + readFileSync, + writeFileSync, + tmpdir, + join, +}; +export type { MockPageState }; diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts deleted file mode 100644 index 99493228..00000000 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ /dev/null @@ -1,9414 +0,0 @@ -import { afterEach, describe, expect, it } from 'bun:test'; -import { spawn } from 'child_process'; -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import * as http from 'node:http'; -import { WebSocketServer } from 'ws'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -type JsonRpcMessage = Record; - -type MockRect = { - x: number; - y: number; - width: number; - height: number; - top: number; - right: number; - bottom: number; - left: number; -}; - -type MockQueryState = { - exists?: boolean; - connected?: boolean; - innerText?: string; - textContent?: string; - rect?: MockRect; - display?: string; - visibility?: string; - opacity?: string; - href?: string; - onclick?: string; - error?: string; -}; - -type MockQueryPlan = MockQueryState | MockQueryState[]; - -type MockClickState = { - error?: string; - disabled?: boolean; - detached?: boolean; - hidden?: boolean; - requireMouseSequence?: boolean; - requireNativeClick?: boolean; - forbidSyntheticClickEvent?: boolean; - cancelMouseDown?: boolean; - cancelMouseUp?: boolean; - detachAfterMouseDown?: boolean; - mouseSequenceError?: string; - label?: string; - expectedOffset?: { x: number; y: number }; - expectedButton?: 'left' | 'middle' | 'right'; - expectedClickCount?: number; - requireDoubleClickEvent?: boolean; -}; - -type MockClickPlan = MockClickState | MockClickState[]; - -type MockHoverState = { - error?: string; - detached?: boolean; - hidden?: boolean; - zeroSized?: boolean; - requireCdpMouseMove?: boolean; - lastMouseMove?: { - x: number; - y: number; - }; -}; - -type MockWaitPlan = { - selectorSnapshots?: Record; - pageTextSequence?: string[]; -}; - -type MockDownloadProgressState = { - receivedBytes: number; - totalBytes: number; - state: 'inProgress' | 'completed' | 'canceled'; - filePath?: string; -}; - -type MockDownloadState = { - guid?: string; - url: string; - suggestedFilename: string; - frameId?: string; - progress?: MockDownloadProgressState[]; -}; - -type MockBrowserState = { - setDownloadBehaviorCalls?: Array<{ - behavior: string; - downloadPath?: string; - eventsEnabled?: boolean; - }>; - canceledDownloadGuids?: string[]; -}; - -type MockPageEventPlan = { - dialogs?: Array<{ type: string; message: string }>; - navigations?: Array<{ url: string; parentId?: string }>; - requests?: Array<{ url: string; method: string }>; - downloads?: MockDownloadState[]; -}; - -type MockFileInputState = { - kind: 'file' | 'nonfile'; - multiple?: boolean; - assignedFiles?: string[]; -}; - -type MockFileInputPlan = MockFileInputState | MockFileInputState[]; - -type MockDropzoneState = { - accepted?: boolean; - acceptedByCancel?: boolean; - requireFiles?: boolean; - receivedEventTypes?: string[]; - receivedFiles?: Array<{ name: string; size: number; type: string }>; - error?: string; -}; - -type MockDropzonePlan = MockDropzoneState | MockDropzoneState[]; - -type MockPointerActionRecord = { - type: string; - x?: number; - y?: number; - button?: string; -}; - -type MockDragPlan = { - recordedActions?: MockPointerActionRecord[]; -}; - -type MockRecordedEvent = - | { - kind: 'click'; - selector: string; - nth?: number; - frameSelector?: string; - pierceShadow?: boolean; - button?: 'left' | 'middle' | 'right'; - clickCount?: number; - offsetX?: number; - offsetY?: number; - timestamp?: number; - } - | { - kind: 'type'; - selector: string; - text: string; - nth?: number; - frameSelector?: string; - pierceShadow?: boolean; - timestamp?: number; - } - | { - kind: 'press_key'; - key: string; - modifiers?: string[]; - timestamp?: number; - } - | { - kind: 'scroll'; - selector?: string; - deltaX?: number; - deltaY?: number; - frameSelector?: string; - pierceShadow?: boolean; - timestamp?: number; - } - | { - kind: 'drag_element'; - selector: string; - targetSelector?: string; - targetX?: number; - targetY?: number; - nth?: number; - targetNth?: number; - frameSelector?: string; - pierceShadow?: boolean; - timestamp?: number; - } - | { - kind: 'pointer_action'; - actions: Array<{ - type: 'move' | 'down' | 'up' | 'pause'; - selector?: string; - x?: number; - y?: number; - button?: 'left' | 'middle' | 'right'; - durationMs?: number; - }>; - timestamp?: number; - }; - -type MockRecordingWarning = { - message: string; -}; - -type MockRecordingPlan = { - events?: MockRecordedEvent[]; - warnings?: MockRecordingWarning[]; - injectionError?: string; - finalizeError?: string; -}; - -type MockFrameState = { - selector: string; - query?: Record; - visibleText?: string; - fileInputs?: Record; - dropzones?: Record; -}; - -type MockShadowRootState = { - hostSelector: string; - query?: Record; - fileInputs?: Record; - dropzones?: Record; -}; - -type MockEvalPlan = Record< - string, - { - result?: unknown; - error?: string; - nonSerializable?: boolean; - unserializableValue?: string; - } ->; - -type MockInterceptRuleMatch = { - url: string; - method: string; - resourceType?: string; - requestId?: string; - requestHeaders?: Record; -}; - -type MockFulfilledRequest = { - requestId: string; - responseCode?: number; - responseHeaders?: Array<{ name: string; value: string }>; - body?: string; -}; - -type MockInterceptState = { - pausedRequests?: MockInterceptRuleMatch[]; - continuedRequestIds?: string[]; - failedRequests?: Array<{ requestId: string; errorReason?: string }>; - fulfilledRequests?: MockFulfilledRequest[]; - fetchEnabledPatterns?: unknown[]; - enableError?: string; - pauseDispatchDelayMs?: number; -}; - -type MockFrameTree = { - frame: { id: string }; - childFrames?: MockFrameTree[]; -}; - -type MockPageState = { - id: string; - title: string; - currentUrl: string; - fileInputs?: Record; - browser?: MockBrowserState; - frameTree?: MockFrameTree; - frameTreeSequence?: MockFrameTree[]; - readyStateSequence?: string[]; - visibleText?: string; - domSnapshot?: string; - navigate?: Record; - click?: Record; - hover?: Record; - query?: Record; - wait?: MockWaitPlan; - eval?: MockEvalPlan; - frames?: MockFrameState[]; - shadowRoots?: MockShadowRootState[]; - dropzones?: Record; - drag?: MockDragPlan; - recording?: MockRecordingPlan; - events?: MockPageEventPlan; - intercept?: MockInterceptState; - screenshot?: { - expectedClip?: { - x: number; - y: number; - width: number; - height: number; - scale: number; - }; - requireScrolledMeasurement?: boolean; - scrolledSelectors?: string[]; - data?: string; - lastCaptureBeyondViewport?: boolean; - lastClip?: { - x: number; - y: number; - width: number; - height: number; - scale: number; - }; - }; - type?: Record< - string, - { - kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable'; - inputType?: string; - value?: string; - expectedValueWhenClearFirst?: string; - expectedValueWhenAppend?: string; - requireFocus?: boolean; - focused?: boolean; - } - >; - keyboard?: { - expectedKey?: string; - expectedModifiers?: string[]; - expectedRepeat?: number; - _seenKeyDownCount?: number; - }; - scroll?: Record< - string, - { - expectedBehavior?: 'into-view' | 'by-offset'; - expectedDeltaX?: number; - expectedDeltaY?: number; - } - >; -}; - -const bundledServerPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs'); - -type RunMcpRequestsOptions = { - serverPath?: string; - childEnv?: NodeJS.ProcessEnv; - responseTimeoutMs?: number; -}; - -function encodeMessage(message: unknown): string { - return `${JSON.stringify(message)}\n`; -} - -function collectResponses( - child: ReturnType, - expectedCount: number, - timeoutMs = 7000 -): Promise>> { - return new Promise((resolve, reject) => { - let buffer = Buffer.alloc(0); - let stderrBuffer = ''; - let settled = false; - const responses: Array> = []; - const fail = (error: Error) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - reject(error); - }; - const finish = () => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve(responses); - }; - const timer = setTimeout(() => { - const details = stderrBuffer.trim(); - fail( - new Error( - details - ? `Timed out waiting for MCP responses\n${details}` - : 'Timed out waiting for MCP responses' - ) - ); - }, timeoutMs); - - function tryParse(): void { - while (true) { - const newlineIndex = buffer.indexOf('\n'); - if (newlineIndex === -1) { - return; - } - - const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); - buffer = buffer.subarray(newlineIndex + 1); - if (!body) { - continue; - } - - responses.push(JSON.parse(body) as Record); - if (responses.length >= expectedCount) { - finish(); - return; - } - } - } - - if (!child.stdout) { - fail(new Error('MCP child stdout is unavailable')); - return; - } - - child.stdout.on('data', (chunk: Buffer) => { - buffer = Buffer.concat([buffer, chunk]); - try { - tryParse(); - } catch (error) { - fail(error instanceof Error ? error : new Error(String(error))); - } - }); - - child.stderr?.on('data', (chunk: Buffer | string) => { - stderrBuffer += chunk.toString(); - }); - - child.on('error', (error) => { - fail(error); - }); - - child.on('exit', (code, signal) => { - if (settled || responses.length >= expectedCount) { - return; - } - const details = stderrBuffer.trim(); - const suffix = details ? `\n${details}` : ''; - fail( - new Error( - `MCP child exited before all responses arrived (code=${code}, signal=${signal})${suffix}` - ) - ); - }); - }); -} - -function getResponseText(message: Record | undefined): string { - const result = (message?.result as { content?: Array<{ text?: string }> }) || {}; - return result.content?.[0]?.text || ''; -} - -function createReplayStep(step: Record): Record { - return step; -} - -function createOrchestrationBlock(block: Record): Record { - return block; -} - -function parseJsonArgument(expression: string, key: string): string | undefined { - const marker = `const ${key} = JSON.parse(`; - const start = expression.indexOf(marker); - if (start === -1) { - return undefined; - } - - const quoteStart = start + marker.length; - const quote = expression[quoteStart]; - if (quote !== '"' && quote !== "'") { - return undefined; - } - - let index = quoteStart + 1; - while (index < expression.length) { - if (expression[index] === '\\') { - index += 2; - continue; - } - if (expression[index] === quote) { - const encoded = expression.slice(quoteStart, index + 1); - const decoded = JSON.parse(encoded) as string; - if (!decoded.startsWith('"') && !decoded.startsWith("'")) { - return undefined; - } - return JSON.parse(decoded) as string; - } - index += 1; - } - - return undefined; -} - -function parseNumberArgument(expression: string, key: string): number | undefined { - const match = expression.match(new RegExp(`const ${key} = ([0-9]+|undefined);`)); - if (!match?.[1] || match[1] === 'undefined') { - return undefined; - } - return Number.parseInt(match[1], 10); -} - -function parseParsedJsonArrayArgument(expression: string, key: string): T[] { - const marker = `const ${key} = JSON.parse(`; - const start = expression.indexOf(marker); - if (start === -1) { - return []; - } - - const quoteStart = start + marker.length; - const quote = expression[quoteStart]; - if (quote !== '"' && quote !== "'") { - return []; - } - - let index = quoteStart + 1; - while (index < expression.length) { - if (expression[index] === '\\') { - index += 2; - continue; - } - if (expression[index] === quote) { - const encoded = expression.slice(quoteStart, index + 1); - const decoded = JSON.parse(encoded) as string; - return JSON.parse(decoded) as T[]; - } - index += 1; - } - - return []; -} - -function parseParsedJsonObjectArgument(expression: string, key: string): T | null { - const marker = `const ${key} = JSON.parse(`; - const start = expression.indexOf(marker); - if (start === -1) { - return null; - } - - const quoteStart = start + marker.length; - const quote = expression[quoteStart]; - if (quote !== '"' && quote !== "'") { - return null; - } - - let index = quoteStart + 1; - while (index < expression.length) { - if (expression[index] === '\\') { - index += 2; - continue; - } - if (expression[index] === quote) { - const encoded = expression.slice(quoteStart, index + 1); - const decoded = JSON.parse(encoded) as string; - return JSON.parse(decoded) as T; - } - index += 1; - } - - return null; -} - -function pickMockMatch( - plan: T | T[] | undefined, - nth = 0 -): { - count: number; - target: T | undefined; -} { - if (Array.isArray(plan)) { - return { count: plan.length, target: plan[nth] }; - } - return { count: plan ? 1 : 0, target: nth === 0 ? plan : undefined }; -} - -function shiftSelectorSnapshot(page: MockPageState, selector: string): MockQueryPlan | undefined { - const queue = page.wait?.selectorSnapshots?.[selector]; - if (!queue || queue.length === 0) { - return page.query?.[selector]; - } - if (queue.length === 1) { - return queue[0]; - } - return queue.shift(); -} - -function getMockFrame(page: MockPageState, frameSelector: string): MockFrameState | undefined { - return page.frames?.find((frame) => frame.selector === frameSelector); -} - -function getMockShadowRoot(page: MockPageState): MockShadowRootState | undefined { - return page.shadowRoots?.[0]; -} - -function getMockDropzoneState( - page: Pick, - selector: string, - options: { nth?: number; frameSelector?: string; pierceShadow?: boolean } = {} -): MockDropzoneState | null { - const nth = options.nth ?? 0; - if (options.frameSelector) { - const frame = page.frames?.find((entry) => entry.selector === options.frameSelector); - const plan = frame?.dropzones?.[selector]; - if (!plan) { - return null; - } - return Array.isArray(plan) ? (plan[nth] ?? null) : plan; - } - if (options.pierceShadow) { - for (const shadowRoot of page.shadowRoots || []) { - const plan = shadowRoot.dropzones?.[selector]; - if (plan) { - return Array.isArray(plan) ? (plan[nth] ?? null) : plan; - } - } - } - const plan = page.dropzones?.[selector]; - if (!plan) { - return null; - } - return Array.isArray(plan) ? (plan[nth] ?? null) : plan; -} - -function pushPointerAction(page: MockPageState, action: MockPointerActionRecord): void { - page.drag = page.drag || {}; - page.drag.recordedActions = page.drag.recordedActions || []; - page.drag.recordedActions.push(action); -} - -function getMockRecordingPlan(page: MockPageState): MockRecordingPlan { - page.recording = page.recording || {}; - return page.recording; -} - -function shiftPageText(page: MockPageState): string { - const queue = page.wait?.pageTextSequence; - if (!queue || queue.length === 0) { - return page.visibleText || ''; - } - if (queue.length === 1) { - return queue[0] || ''; - } - return queue.shift() || ''; -} - -function resolveNodeModulesPath(): string { - const candidates = [ - join(process.cwd(), 'node_modules'), - join(process.cwd(), '..', 'node_modules'), - join(process.cwd(), '..', '..', 'node_modules'), - ]; - return ( - candidates.find((candidate) => existsSync(join(candidate, 'ws'))) || - candidates.find((candidate) => existsSync(candidate)) || - candidates[0] - ); -} - -function createMockBrowser(pagesInput: MockPageState[]) { - let tempDir = ''; - let httpServer: http.Server | null = null; - let wsServer: WebSocketServer | null = null; - let browserSocketPath = ''; - const pageStates = new Map(); - let nextPageCounter = pagesInput.length + 1; - - for (const [index, page] of pagesInput.entries()) { - if (page.visibleText === undefined) { - page.visibleText = 'Hello from visible text'; - } - if (page.domSnapshot === undefined) { - page.domSnapshot = 'Hello from DOM snapshot'; - } - pageStates.set(`/devtools/page/${index + 1}`, page); - } - - async function start(options: RunMcpRequestsOptions = {}) { - const entryServerPath = options.serverPath || bundledServerPath; - const childEnv = options.childEnv || {}; - tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-')); - - const port = await new Promise((resolve, reject) => { - httpServer = http.createServer((req, res) => { - const address = httpServer?.address(); - const serverPort = address && typeof address !== 'string' ? address.port : 0; - - if (req.url === '/json/list') { - const pageEntries = Array.from(pageStates.entries()); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify([ - ...pageEntries.map(([wsPath, page]) => ({ - id: page.id, - type: 'page', - title: page.title, - url: page.currentUrl, - webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, - })), - { - id: 'browser-target', - type: 'browser', - title: 'Browser', - url: '', - webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${browserSocketPath || '/devtools/browser'}`, - }, - ]) - ); - return; - } - - if (req.url?.startsWith('/json/new')) { - const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`); - const requestedUrl = parsed.searchParams.get('url') || 'about:blank'; - const wsPath = `/devtools/page/${nextPageCounter}`; - const newPage: MockPageState = { - id: `page-${nextPageCounter}`, - title: requestedUrl === 'about:blank' ? 'about:blank' : requestedUrl, - currentUrl: requestedUrl, - visibleText: 'New page visible text', - domSnapshot: 'New page', - }; - pageStates.set(wsPath, newPage); - nextPageCounter += 1; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - id: newPage.id, - type: 'page', - title: newPage.title, - url: newPage.currentUrl, - webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, - }) - ); - return; - } - - if (req.url?.startsWith('/json/close/')) { - const targetId = decodeURIComponent(req.url.slice('/json/close/'.length)); - const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId); - if (!entry) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'page not found' })); - return; - } - pageStates.delete(entry[0]); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ id: targetId })); - return; - } - - res.writeHead(404); - res.end('not found'); - }); - - httpServer.once('error', reject); - httpServer.listen(0, '127.0.0.1', () => { - const address = httpServer?.address(); - if (!address || typeof address === 'string') { - reject(new Error('Failed to resolve mock browser server port')); - return; - } - resolve(address.port); - }); - }); - - browserSocketPath = '/devtools/browser'; - wsServer = new WebSocketServer({ server: httpServer as http.Server }); - wsServer.on('connection', (socket, request) => { - if ((request.url || '') === browserSocketPath) { - const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); - - socket.on('message', (raw) => { - const message = JSON.parse(raw.toString()) as { - id: number; - method: string; - params?: Record; - }; - - function reply(result: unknown): void { - socket.send(JSON.stringify({ id: message.id, result })); - } - - if (message.method === 'Browser.setDownloadBehavior') { - browserState.setDownloadBehaviorCalls = browserState.setDownloadBehaviorCalls || []; - browserState.setDownloadBehaviorCalls.push({ - behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', - downloadPath: - typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, - eventsEnabled: - typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, - }); - reply({}); - return; - } - - if (message.method === 'Browser.cancelDownload') { - browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || []; - browserState.canceledDownloadGuids.push(String(message.params?.guid || '')); - reply({}); - return; - } - }); - - for (const page of pagesInput) { - for (const [index, download] of (page.events?.downloads || []).entries()) { - const guid = download.guid || `${page.id}-download-${index + 1}`; - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Browser.downloadWillBegin', - params: { - frameId: download.frameId || `frame-${page.id}`, - guid, - url: download.url, - suggestedFilename: download.suggestedFilename, - }, - }) - ); - }, 10 + index * 40); - - for (const [progressIndex, progress] of (download.progress || []).entries()) { - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Browser.downloadProgress', - params: { - guid, - totalBytes: progress.totalBytes, - receivedBytes: progress.receivedBytes, - state: progress.state, - filePath: progress.filePath, - }, - }) - ); - }, 20 + index * 40 + progressIndex * 20); - } - } - } - return; - } - - const page = pageStates.get(request.url || ''); - if (!page) { - socket.close(); - return; - } - - const remoteObjects = new Map(); - - socket.on('message', (raw) => { - const message = JSON.parse(raw.toString()) as { - id: number; - method: string; - params?: Record; - }; - - function reply(result: unknown): void { - socket.send(JSON.stringify({ id: message.id, result })); - } - - function replyError(errorText: string): void { - socket.send( - JSON.stringify({ - id: message.id, - result: { result: { subtype: 'error', description: errorText } }, - }) - ); - } - - if (message.method === 'Page.navigate') { - const targetUrl = typeof message.params?.url === 'string' ? message.params.url : ''; - const navigatePlan = page.navigate?.[targetUrl]; - if (navigatePlan?.errorText) { - reply({ frameId: 'frame-1', errorText: navigatePlan.errorText }); - return; - } - if (navigatePlan) { - page.currentUrl = navigatePlan.finalUrl; - page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])]; - } - reply({ frameId: 'frame-1' }); - return; - } - - if (message.method === 'Page.getFrameTree') { - const nextFrameTree = page.frameTreeSequence?.shift(); - reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); - return; - } - - if (message.method === 'Page.captureScreenshot') { - if (!page.screenshot) { - reply({ data: '' }); - return; - } - page.screenshot.lastCaptureBeyondViewport = - message.params?.captureBeyondViewport === true; - const clip = - message.params?.clip && typeof message.params.clip === 'object' - ? (message.params.clip as Record) - : null; - page.screenshot.lastClip = clip - ? { - x: Number(clip.x), - y: Number(clip.y), - width: Number(clip.width), - height: Number(clip.height), - scale: Number(clip.scale), - } - : undefined; - if (page.screenshot.expectedClip) { - expect(page.screenshot.lastClip).toEqual(page.screenshot.expectedClip); - } - reply({ data: page.screenshot.data || '' }); - return; - } - - if (message.method === 'Input.dispatchMouseEvent') { - const type = typeof message.params?.type === 'string' ? message.params.type : ''; - const x = Number(message.params?.x); - const y = Number(message.params?.y); - const button = typeof message.params?.button === 'string' ? message.params.button : undefined; - for (const hoverPlan of Object.values(page.hover || {})) { - if (type === 'mouseMoved') { - hoverPlan.lastMouseMove = { x, y }; - } - } - pushPointerAction(page, { type, x, y, button }); - reply({}); - return; - } - - if (message.method === 'Input.dispatchKeyEvent') { - const type = typeof message.params?.type === 'string' ? message.params.type : ''; - const key = typeof message.params?.key === 'string' ? message.params.key : ''; - const modifiersMask = Number(message.params?.modifiers || 0); - const modifiers = [ - ...(modifiersMask & 1 ? ['Alt'] : []), - ...(modifiersMask & 2 ? ['Control'] : []), - ...(modifiersMask & 4 ? ['Meta'] : []), - ...(modifiersMask & 8 ? ['Shift'] : []), - ]; - const keyboardPlan = page.keyboard; - if (type === 'keyDown') { - if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { - replyError(`unexpected key: ${key}`); - return; - } - if ( - keyboardPlan?.expectedModifiers && - JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) - ) { - replyError(`unexpected modifiers for key: ${key}`); - return; - } - if (keyboardPlan) { - keyboardPlan._seenKeyDownCount = (keyboardPlan._seenKeyDownCount || 0) + 1; - } - } - if ( - type === 'keyUp' && - typeof keyboardPlan?.expectedRepeat === 'number' && - (keyboardPlan._seenKeyDownCount || 0) !== keyboardPlan.expectedRepeat - ) { - replyError(`unexpected repeat for key: ${key}`); - return; - } - reply({}); - return; - } - - if (message.method === 'Page.enable' || message.method === 'Network.enable') { - reply({}); - if (message.method === 'Page.enable') { - const dialog = page.events?.dialogs?.[0]; - const navigations = page.events?.navigations || []; - if (dialog) { - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Page.javascriptDialogOpening', - params: { message: dialog.message, type: dialog.type }, - }) - ); - }, 10); - } - navigations.forEach((navigation, index) => { - setTimeout( - () => { - socket.send( - JSON.stringify({ - method: 'Page.frameNavigated', - params: { frame: { url: navigation.url, parentId: navigation.parentId } }, - }) - ); - }, - 10 + index * 10 - ); - }); - } - if (message.method === 'Network.enable') { - const requestPlan = page.events?.requests?.[0]; - if (requestPlan) { - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Network.requestWillBeSent', - params: { request: { url: requestPlan.url, method: requestPlan.method } }, - }) - ); - }, 10); - } - } - return; - } - - if (message.method === 'Fetch.enable') { - page.intercept = page.intercept || {}; - page.intercept.fetchEnabledPatterns = Array.isArray(message.params?.patterns) - ? (message.params?.patterns as unknown[]) - : []; - if (page.intercept.enableError) { - socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })); - return; - } - reply({}); - const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10; - for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) { - setTimeout(() => { - socket.send( - JSON.stringify({ - method: 'Fetch.requestPaused', - params: { - requestId: paused.requestId || `fetch-${index + 1}`, - resourceType: paused.resourceType || 'XHR', - request: { - url: paused.url, - method: paused.method, - headers: paused.requestHeaders || {}, - }, - }, - }) - ); - }, pauseDispatchDelayMs + index * 10); - } - return; - } - - if (message.method === 'Fetch.continueRequest') { - page.intercept = page.intercept || {}; - page.intercept.continuedRequestIds = page.intercept.continuedRequestIds || []; - page.intercept.continuedRequestIds.push(String(message.params?.requestId || '')); - reply({}); - return; - } - - if (message.method === 'Fetch.failRequest') { - page.intercept = page.intercept || {}; - page.intercept.failedRequests = page.intercept.failedRequests || []; - page.intercept.failedRequests.push({ - requestId: String(message.params?.requestId || ''), - errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', - }); - reply({}); - return; - } - - if (message.method === 'Fetch.fulfillRequest') { - page.intercept = page.intercept || {}; - page.intercept.fulfilledRequests = page.intercept.fulfilledRequests || []; - page.intercept.fulfilledRequests.push({ - requestId: String(message.params?.requestId || ''), - responseCode: - typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined, - responseHeaders: Array.isArray(message.params?.responseHeaders) - ? (message.params.responseHeaders as Array<{ name: string; value: string }>) - : [], - body: typeof message.params?.body === 'string' ? message.params.body : '', - }); - reply({}); - return; - } - - if (message.method === 'DOM.setFileInputFiles') { - const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : ''; - const target = remoteObjects.get(objectId); - if (!target) { - socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } })); - return; - } - target.assignedFiles = Array.isArray(message.params?.files) - ? (message.params.files as unknown[]).map((entry) => String(entry)) - : []; - reply({}); - return; - } - - if (message.method !== 'Runtime.evaluate') { - return; - } - - const expression = String(message.params?.expression || ''); - const recordingPayload = parseParsedJsonObjectArgument<{ - events?: MockRecordedEvent[]; - warnings?: MockRecordingWarning[]; - }>(expression, 'recordingPayload'); - - if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ =')) { - const plan = getMockRecordingPlan(page); - if (plan.injectionError) { - reply({ - result: { - type: 'object', - subtype: 'error', - description: plan.injectionError, - }, - }); - return; - } - - if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { - plan.events = recordingPayload.events || []; - plan.warnings = recordingPayload.warnings || []; - } - - reply({ result: { type: 'object', value: { installed: true } } }); - return; - } - - if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { - const plan = getMockRecordingPlan(page); - if (plan.finalizeError) { - socket.send( - JSON.stringify({ - id: message.id, - result: { exceptionDetails: { text: plan.finalizeError } }, - }) - ); - return; - } - reply({ - result: { - type: 'object', - value: { - events: plan.events || [], - warnings: plan.warnings || [], - }, - }, - }); - return; - } - - if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const nth = parseNumberArgument(expression, 'nth') ?? 0; - const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; - const pierceShadow = expression.includes('const pierceShadow = true'); - const filePayloads = parseParsedJsonArrayArgument<{ - name: string; - size: number; - mimeType: string; - }>(expression, 'filePayloads'); - const eventTypes = Array.from( - expression.matchAll(/new DragEvent\('(dragenter|dragover|drop)'/g) - ).map((match) => match[1]); - - const target = getMockDropzoneState(page, selector, { - nth, - frameSelector, - pierceShadow, - }); - if (!target) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (target.error) { - replyError(target.error); - return; - } - if (eventTypes.length === 0) { - replyError(`drag event sequence not found for selector: ${selector}`); - return; - } - if (target.requireFiles && filePayloads.length === 0) { - reply({ result: { type: 'object', value: { accepted: false } } }); - return; - } - target.receivedEventTypes = eventTypes; - target.receivedFiles = filePayloads.map((file) => ({ - name: file.name, - size: file.size, - type: file.mimeType, - })); - reply({ - result: { - type: 'object', - value: { - accepted: target.acceptedByCancel === true ? true : target.accepted !== false, - }, - }, - }); - return; - } - - if (page.eval?.[expression]) { - const evalPlan = page.eval[expression]; - if (evalPlan.error) { - socket.send( - JSON.stringify({ - id: message.id, - result: { exceptionDetails: { text: evalPlan.error } }, - }) - ); - return; - } - if (evalPlan.nonSerializable) { - socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } })); - return; - } - if (typeof evalPlan.unserializableValue === 'string') { - socket.send( - JSON.stringify({ - id: message.id, - result: { - result: { - type: 'number', - unserializableValue: evalPlan.unserializableValue, - }, - }, - }) - ); - return; - } - socket.send( - JSON.stringify({ - id: message.id, - result: { result: { type: 'object', value: evalPlan.result } }, - }) - ); - return; - } - - if (expression === '0') { - reply({ result: { type: 'number', value: 0 } }); - return; - } - - if (expression.includes('element is not a file input for selector:')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const nth = parseNumberArgument(expression, 'nth') ?? 0; - const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; - const pierceShadow = expression.includes('const pierceShadow = true'); - - const fileInputPlan = frameSelector - ? getMockFrame(page, frameSelector)?.fileInputs?.[selector] - : pierceShadow - ? getMockShadowRoot(page)?.fileInputs?.[selector] - : page.fileInputs?.[selector]; - - const { count, target } = pickMockMatch(fileInputPlan, nth); - if (!target || count <= nth) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (target.kind !== 'file') { - replyError(`element is not a file input for selector: ${selector}`); - return; - } - - const objectId = `file-input:${page.id}:${selector}:${nth}:${frameSelector || 'root'}:${pierceShadow ? 'shadow' : 'light'}`; - remoteObjects.set(objectId, target); - socket.send( - JSON.stringify({ - id: message.id, - result: { - result: { - type: 'object', - subtype: 'node', - className: 'HTMLInputElement', - objectId, - }, - }, - }) - ); - return; - } - - if (expression.includes('document.title') && expression.includes('location.href')) { - reply({ - result: { - type: 'string', - value: JSON.stringify({ title: page.title, url: page.currentUrl }), - }, - }); - return; - } - - if (expression.includes('document.body ? document.body.innerText')) { - reply({ result: { type: 'string', value: shiftPageText(page) } }); - return; - } - - if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) { - reply({ result: { type: 'string', value: page.domSnapshot || '' } }); - return; - } - - if (expression.includes('document.readyState') && expression.includes('location.href')) { - const readyState = page.readyStateSequence?.shift() || 'complete'; - reply({ - result: { - type: 'string', - value: JSON.stringify({ href: page.currentUrl, readyState }), - }, - }); - return; - } - - if (expression.includes('scrollIntoView') && expression.includes('resolvedOffsetX')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const nth = parseNumberArgument(expression, 'nth') ?? 0; - const clickPlan = page.click?.[selector]; - const { count, target: resolvedClickPlan } = pickMockMatch(clickPlan, nth); - const attemptedMouseDown = expression.includes("dispatchMouseEvent('mousedown'"); - const attemptedMouseUp = expression.includes("dispatchMouseEvent('mouseup'"); - const attemptedMouseSequence = attemptedMouseDown && attemptedMouseUp; - const attemptedClickEvent = expression.includes("dispatchMouseEvent('click'"); - const attemptedDoubleClickEvent = expression.includes("new MouseEvent('dblclick'"); - const readsDispatchResult = expression.includes('const dispatchResult = {'); - const gatesNativeClickOnDispatchResult = expression.includes( - 'if (!dispatchResult.shouldActivate)' - ); - const checksIsConnectedBeforeNativeClick = expression.includes( - 'if (!element.isConnected)' - ); - const catchIndex = expression.indexOf('catch (mouseError) {'); - const catchBlockEnd = catchIndex === -1 ? -1 : expression.indexOf('\n }', catchIndex); - const nativeClickIndexes = Array.from(expression.matchAll(/element\.click\(\)/g)).map( - (match) => match.index ?? -1 - ); - const attemptedFallbackClick = nativeClickIndexes.some( - (index) => - catchIndex !== -1 && - catchBlockEnd !== -1 && - index > catchIndex && - index < catchBlockEnd - ); - const attemptedNativeClickOutsideCatch = nativeClickIndexes.some( - (index) => - catchIndex === -1 || - catchBlockEnd === -1 || - index < catchIndex || - index > catchBlockEnd - ); - const offsetX = parseNumberArgument(expression, 'offsetX'); - const offsetY = parseNumberArgument(expression, 'offsetY'); - const button = parseJsonArgument(expression, 'button') || 'left'; - const clickCount = parseNumberArgument(expression, 'clickCount') ?? 1; - if (!resolvedClickPlan) { - replyError(`element index ${nth} is out of range for selector: ${selector}`); - return; - } - if (count <= nth) { - replyError(`element index ${nth} is out of range for selector: ${selector}`); - return; - } - if (resolvedClickPlan.detached && expression.includes('element.isConnected')) { - replyError(`element is detached for selector: ${selector}`); - return; - } - if (resolvedClickPlan.disabled) { - replyError(`element is disabled for selector: ${selector}`); - return; - } - if (resolvedClickPlan.hidden && expression.includes('getBoundingClientRect')) { - replyError(`element is hidden or not interactable for selector: ${selector}`); - return; - } - if (resolvedClickPlan.requireMouseSequence && !attemptedMouseSequence) { - replyError(`mousedown/mouseup required for selector: ${selector}`); - return; - } - if (resolvedClickPlan.forbidSyntheticClickEvent && attemptedClickEvent) { - replyError(`synthetic click event forbidden for selector: ${selector}`); - return; - } - if ( - (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && - !readsDispatchResult - ) { - replyError(`dispatch result must be checked for selector: ${selector}`); - return; - } - if ( - (resolvedClickPlan.cancelMouseDown || resolvedClickPlan.cancelMouseUp) && - !gatesNativeClickOnDispatchResult - ) { - replyError(`native click must be gated for selector: ${selector}`); - return; - } - if (resolvedClickPlan.detachAfterMouseDown && !checksIsConnectedBeforeNativeClick) { - replyError(`connected state must be rechecked for selector: ${selector}`); - return; - } - if (resolvedClickPlan.requireNativeClick && !attemptedNativeClickOutsideCatch) { - replyError(`native click required for selector: ${selector}`); - return; - } - if ( - resolvedClickPlan.expectedOffset && - (offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y) - ) { - replyError(`unexpected click offset for selector: ${selector}`); - return; - } - if (resolvedClickPlan.expectedButton && button !== resolvedClickPlan.expectedButton) { - replyError(`unexpected click button for selector: ${selector}`); - return; - } - if ( - typeof resolvedClickPlan.expectedClickCount === 'number' && - clickCount !== resolvedClickPlan.expectedClickCount - ) { - replyError(`unexpected click count for selector: ${selector}`); - return; - } - if ( - resolvedClickPlan.requireDoubleClickEvent && - clickCount === 2 && - !attemptedDoubleClickEvent - ) { - replyError(`dblclick event required for selector: ${selector}`); - return; - } - if (resolvedClickPlan.mouseSequenceError) { - if (!attemptedMouseSequence) { - replyError(`mousedown/mouseup required for selector: ${selector}`); - return; - } - if (attemptedFallbackClick || attemptedNativeClickOutsideCatch) { - reply({ - result: { - type: 'string', - value: JSON.stringify({ - resolvedOffsetX: offsetX ?? 50, - resolvedOffsetY: offsetY ?? 10, - button, - clickCount, - }), - }, - }); - return; - } - replyError(resolvedClickPlan.mouseSequenceError); - return; - } - if (resolvedClickPlan.error) { - replyError(resolvedClickPlan.error); - return; - } - reply({ - result: { - type: 'string', - value: JSON.stringify({ - resolvedOffsetX: offsetX ?? 50, - resolvedOffsetY: offsetY ?? 10, - button, - clickCount, - }), - }, - }); - return; - } - - if ( - expression.includes('getComputedStyle(element)') && - (expression.includes('boundingClientRect') || - expression.includes('visibleClip') || - expression.includes('centerPoint') || - expression.includes('querySelectorAll(selector)')) - ) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const nth = parseNumberArgument(expression, 'nth'); - const frameSelector = parseJsonArgument(expression, 'frameSelector') || ''; - const pierceShadow = expression.includes('const pierceShadow = true'); - const frame = frameSelector ? getMockFrame(page, frameSelector) : undefined; - const shadowRoot = pierceShadow ? getMockShadowRoot(page) : undefined; - const scopedQuery = frame?.query?.[selector] ?? shadowRoot?.query?.[selector]; - const frameRootPlan = frame?.query?.[frameSelector]; - const frameRect = !Array.isArray(frameRootPlan) ? frameRootPlan?.rect : undefined; - const applyFrameOffset = (rect?: MockRect): MockRect | undefined => { - if (!rect || !frameRect) { - return rect; - } - return { - x: rect.x + frameRect.left, - y: rect.y + frameRect.top, - width: rect.width, - height: rect.height, - top: rect.top + frameRect.top, - right: rect.right + frameRect.left, - bottom: rect.bottom + frameRect.top, - left: rect.left + frameRect.left, - }; - }; - const queryPlan = scopedQuery ?? shiftSelectorSnapshot(page, selector); - if ( - page.screenshot?.requireScrolledMeasurement && - (selector in (page.query || {}) || Boolean(scopedQuery)) && - !expression.includes('scrollIntoView') - ) { - replyError(`scrollIntoView required for selector: ${selector}`); - return; - } - if (Array.isArray(queryPlan) && expression.includes('querySelectorAll(selector)')) { - const targetIndex = nth ?? 0; - const target = queryPlan[targetIndex]; - if (target?.error) { - replyError(target.error); - return; - } - if (queryPlan.length <= targetIndex || target?.exists === false || !target) { - reply({ - result: { - type: 'string', - value: JSON.stringify({ - exists: - nth === undefined ? queryPlan.length > 0 : queryPlan.length > targetIndex, - count: queryPlan.length, - targetIndex, - targetMissing: true, - }), - }, - }); - return; - } - const rect = applyFrameOffset(target.rect); - const text = target.innerText || target.textContent || ''; - reply({ - result: { - type: 'string', - value: JSON.stringify({ - exists: true, - count: queryPlan.length, - targetIndex, - connected: target.connected !== false, - text, - innerText: target.innerText || '', - textContent: target.textContent || '', - boundingClientRect: rect, - display: target.display || 'block', - visibility: target.visibility || 'visible', - opacity: target.opacity || '1', - href: target.href || '', - onclick: target.onclick || '', - interactable: - target.connected !== false && - (target.display || 'block') !== 'none' && - (target.visibility || 'visible') !== 'hidden' && - Boolean(rect && rect.width > 0 && rect.height > 0), - }), - }, - }); - return; - } - const resolvedQueryPlan = Array.isArray(queryPlan) ? queryPlan.shift() : queryPlan; - if (resolvedQueryPlan?.error) { - replyError(resolvedQueryPlan.error); - return; - } - if (resolvedQueryPlan?.exists === false || !resolvedQueryPlan) { - reply({ result: { type: 'string', value: JSON.stringify({ exists: false }) } }); - return; - } - const rect = applyFrameOffset(resolvedQueryPlan.rect); - const text = resolvedQueryPlan.innerText || resolvedQueryPlan.textContent || ''; - const viewportWidth = 1280; - const viewportHeight = 720; - const clipX = Math.max(0, rect?.left ?? 0); - const clipY = Math.max(0, rect?.top ?? 0); - const clipRight = Math.min(viewportWidth, rect?.right ?? 0); - const clipBottom = Math.min(viewportHeight, rect?.bottom ?? 0); - reply({ - result: { - type: 'string', - value: JSON.stringify({ - exists: true, - connected: resolvedQueryPlan.connected !== false, - text, - innerText: resolvedQueryPlan.innerText || '', - textContent: resolvedQueryPlan.textContent || '', - boundingClientRect: rect, - display: resolvedQueryPlan.display || 'block', - visibility: resolvedQueryPlan.visibility || 'visible', - opacity: resolvedQueryPlan.opacity || '1', - interactable: - resolvedQueryPlan.connected !== false && - (resolvedQueryPlan.display || 'block') !== 'none' && - (resolvedQueryPlan.visibility || 'visible') !== 'hidden' && - Boolean(rect && rect.width > 0 && rect.height > 0), - centerPoint: rect - ? { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - } - : undefined, - visibleClip: rect - ? { - x: clipX, - y: clipY, - width: Math.max(0, clipRight - clipX), - height: Math.max(0, clipBottom - clipY), - scale: 1, - } - : undefined, - }), - }, - }); - return; - } - - if ( - expression.includes("dispatch('mouseover')") && - expression.includes("dispatch('mouseenter')") && - expression.includes("dispatch('mousemove')") - ) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const hoverPlan = page.hover?.[selector]; - if (!hoverPlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (hoverPlan.detached) { - replyError(`element is detached for selector: ${selector}`); - return; - } - if (hoverPlan.hidden || hoverPlan.zeroSized) { - replyError(`element is hidden or not interactable for selector: ${selector}`); - return; - } - if (hoverPlan.requireCdpMouseMove && !hoverPlan.lastMouseMove) { - replyError(`real mouse movement required for selector: ${selector}`); - return; - } - if (hoverPlan.error) { - replyError(hoverPlan.error); - return; - } - reply({ result: { type: 'string', value: 'ok' } }); - return; - } - - if (expression.includes('focusTarget') && expression.includes('typedLength')) { - const selector = parseJsonArgument(expression, 'selector') || ''; - const text = parseJsonArgument(expression, 'text') ?? ''; - const clearFirst = expression.includes('const clearFirst = true'); - const typePlan = page.type?.[selector]; - if (!typePlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (typePlan.kind === 'unsupported') { - replyError(`element is not text-editable for selector: ${selector}`); - return; - } - if (typePlan.kind === 'noneditable') { - replyError(`element is not text-editable for selector: ${selector}`); - return; - } - if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) { - replyError(`focus was not requested for selector: ${selector}`); - return; - } - - const currentValue = typePlan.value || ''; - const expectedValue = clearFirst - ? (typePlan.expectedValueWhenClearFirst ?? text) - : (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`); - - typePlan.focused = true; - typePlan.value = expectedValue; - reply({ - result: { - type: 'string', - value: JSON.stringify({ - value: expectedValue, - typedLength: expectedValue.length, - }), - }, - }); - return; - } - - if (expression.includes('new KeyboardEvent(')) { - const key = parseJsonArgument(expression, 'key') || ''; - const repeat = parseNumberArgument(expression, 'repeat') ?? 1; - const modifiersMatch = expression.match(/const modifiers = (\[[^\n;]*\]);/); - const modifiers = modifiersMatch ? (JSON.parse(modifiersMatch[1]) as string[]) : []; - const keyboardPlan = page.keyboard; - if (keyboardPlan?.expectedKey && key !== keyboardPlan.expectedKey) { - replyError(`unexpected key: ${key}`); - return; - } - if ( - keyboardPlan?.expectedModifiers && - JSON.stringify(modifiers) !== JSON.stringify(keyboardPlan.expectedModifiers) - ) { - replyError(`unexpected modifiers for key: ${key}`); - return; - } - if ( - typeof keyboardPlan?.expectedRepeat === 'number' && - repeat !== keyboardPlan.expectedRepeat - ) { - replyError(`unexpected repeat for key: ${key}`); - return; - } - reply({ - result: { - type: 'string', - value: JSON.stringify({ key, modifiers, repeat }), - }, - }); - return; - } - - if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) { - const selector = parseJsonArgument(expression, 'selector'); - const behavior = parseJsonArgument(expression, 'behavior') || ''; - const deltaX = Number(expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); - const deltaY = Number(expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0); - const scrollPlan = selector ? page.scroll?.[selector] : undefined; - if (selector && !scrollPlan) { - replyError(`element not found for selector: ${selector}`); - return; - } - if (scrollPlan?.expectedBehavior && behavior !== scrollPlan.expectedBehavior) { - replyError(`unexpected scroll behavior for selector: ${selector}`); - return; - } - if ( - typeof scrollPlan?.expectedDeltaX === 'number' && - deltaX !== scrollPlan.expectedDeltaX - ) { - replyError(`unexpected deltaX for selector: ${selector}`); - return; - } - if ( - typeof scrollPlan?.expectedDeltaY === 'number' && - deltaY !== scrollPlan.expectedDeltaY - ) { - replyError(`unexpected deltaY for selector: ${selector}`); - return; - } - reply({ - result: { - type: 'string', - value: JSON.stringify( - selector - ? { scope: 'element', selector, behavior, deltaX, deltaY } - : { scope: 'page', behavior, deltaX, deltaY } - ), - }, - }); - return; - } - }); - }); - - const child = spawn('node', [entryServerPath], { - cwd: tempDir, - env: { - ...process.env, - ...childEnv, - CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - return child; - } - - async function stop() { - await new Promise((resolve) => { - wsServer?.close(() => resolve()); - if (!wsServer) resolve(); - }); - wsServer = null; - - await new Promise((resolve) => { - httpServer?.close(() => resolve()); - if (!httpServer) resolve(); - }); - httpServer = null; - - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - tempDir = ''; - } - } - - return { start, stop }; -} - -async function runMcpRequests( - pages: MockPageState[], - requests: JsonRpcMessage[], - options: RunMcpRequestsOptions = {} -) { - const browser = createMockBrowser(pages); - const child = await browser.start(options); - - try { - const responsesPromise = collectResponses( - child, - requests.length + 1, - options.responseTimeoutMs - ); - child.stdin.write( - encodeMessage({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'bun-test', version: '1.0.0' }, - }, - }) - ); - - for (const request of requests) { - child.stdin.write(encodeMessage(request)); - } - child.stdin.end(); - - return await responsesPromise; - } finally { - child.kill(); - await browser.stop(); - } -} - -describe('ccs-browser MCP server', () => { - afterEach(() => { - // Cleanup is handled per test via runMcpRequests/createMockBrowser. - }); - - it('lists browser tools including navigate, click, type, and screenshot', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], - [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }] - ); - - const tools = ( - responses.find((message) => message.id === 2)?.result as { - tools: Array<{ - name: string; - description?: string; - inputSchema?: { properties?: Record }; - }>; - } - ).tools; - - expect(tools.map((tool) => tool.name)).toEqual([ - 'browser_get_session_info', - 'browser_get_url_and_title', - 'browser_get_visible_text', - 'browser_get_dom_snapshot', - 'browser_navigate', - 'browser_click', - 'browser_type', - 'browser_press_key', - 'browser_scroll', - 'browser_select_page', - 'browser_open_page', - 'browser_close_page', - 'browser_add_intercept_rule', - 'browser_remove_intercept_rule', - 'browser_list_intercept_rules', - 'browser_list_requests', - 'browser_set_download_behavior', - 'browser_list_downloads', - 'browser_cancel_download', - 'browser_set_file_input', - 'browser_drag_files', - 'browser_drag_element', - 'browser_pointer_action', - 'browser_start_recording', - 'browser_stop_recording', - 'browser_get_recording', - 'browser_clear_recording', - 'browser_start_replay', - 'browser_get_replay', - 'browser_cancel_replay', - 'browser_start_orchestration', - 'browser_get_orchestration', - 'browser_cancel_orchestration', - 'browser_export_artifact', - 'browser_import_artifact', - 'browser_list_artifacts', - 'browser_delete_artifact', - 'browser_take_screenshot', - 'browser_wait_for', - 'browser_eval', - 'browser_hover', - 'browser_query', - 'browser_take_element_screenshot', - 'browser_wait_for_event', - ]); - - const clickTool = tools.find((tool) => tool.name === 'browser_click'); - expect(clickTool?.description).toContain('mouse event chain'); - expect(clickTool?.description).not.toContain('synthetic element.click()'); - expect(clickTool?.inputSchema?.properties?.offsetX).toMatchObject({ type: 'number' }); - expect(clickTool?.inputSchema?.properties?.offsetY).toMatchObject({ type: 'number' }); - expect(clickTool?.inputSchema?.properties?.button).toMatchObject({ type: 'string' }); - expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({ - type: 'integer', - minimum: 1, - }); - - const keyTool = tools.find((tool) => tool.name === 'browser_press_key'); - expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' }); - expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' }); - - const scrollTool = tools.find((tool) => tool.name === 'browser_scroll'); - expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' }); - expect(scrollTool?.inputSchema?.properties?.deltaY).toMatchObject({ type: 'number' }); - - const selectTool = tools.find((tool) => tool.name === 'browser_select_page'); - expect(selectTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(selectTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - - const openTool = tools.find((tool) => tool.name === 'browser_open_page'); - expect(openTool?.inputSchema?.properties?.url).toMatchObject({ type: 'string' }); - - const closeTool = tools.find((tool) => tool.name === 'browser_close_page'); - expect(closeTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(closeTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - - const addRuleTool = tools.find((tool) => tool.name === 'browser_add_intercept_rule'); - expect(addRuleTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(addRuleTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.urlIncludes).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.method).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.resourceType).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.urlPattern).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.urlRegex).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.headerMatchers).toMatchObject({ type: 'array' }); - expect(addRuleTool?.inputSchema?.properties?.priority).toMatchObject({ type: 'integer' }); - expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.statusCode).toMatchObject({ type: 'integer' }); - expect(addRuleTool?.inputSchema?.properties?.responseHeaders).toMatchObject({ type: 'array' }); - expect(addRuleTool?.inputSchema?.properties?.headers).toBeUndefined(); - expect(addRuleTool?.inputSchema?.properties?.body).toMatchObject({ type: 'string' }); - expect(addRuleTool?.inputSchema?.properties?.contentType).toMatchObject({ type: 'string' }); - - const removeRuleTool = tools.find((tool) => tool.name === 'browser_remove_intercept_rule'); - expect(removeRuleTool?.inputSchema?.properties?.ruleId).toMatchObject({ type: 'string' }); - - const listRulesTool = tools.find((tool) => tool.name === 'browser_list_intercept_rules'); - expect(listRulesTool?.inputSchema).toMatchObject({ type: 'object' }); - - const listRequestsTool = tools.find((tool) => tool.name === 'browser_list_requests'); - expect(listRequestsTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(listRequestsTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - expect(listRequestsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); - - const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior'); - expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' }); - expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' }); - expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' }); - - const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads'); - expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' }); - expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined(); - - const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download'); - expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' }); - expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' }); - - const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input'); - expect(uploadTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); - expect(uploadTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); - expect(uploadTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(uploadTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - expect(uploadTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); - expect(uploadTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); - expect(uploadTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); - - const dragFilesTool = tools.find((tool) => tool.name === 'browser_drag_files'); - expect(dragFilesTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); - expect(dragFilesTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' }); - expect(dragFilesTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(dragFilesTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - expect(dragFilesTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' }); - expect(dragFilesTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' }); - expect(dragFilesTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' }); - - const dragElementTool = tools.find((tool) => tool.name === 'browser_drag_element'); - expect(dragElementTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' }); - expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ type: 'string' }); - expect(dragElementTool?.inputSchema?.properties?.targetX).toMatchObject({ type: 'number' }); - expect(dragElementTool?.inputSchema?.properties?.targetY).toMatchObject({ type: 'number' }); - expect(dragElementTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'integer' }); - - const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action'); - expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' }); - - const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording'); - expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - - const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording'); - expect(stopRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); - - const getRecordingTool = tools.find((tool) => tool.name === 'browser_get_recording'); - expect(getRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); - - const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording'); - expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' }); - - const startReplayTool = tools.find((tool) => tool.name === 'browser_start_replay'); - expect(startReplayTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'array' }); - expect(startReplayTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(startReplayTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - - const getReplayTool = tools.find((tool) => tool.name === 'browser_get_replay'); - expect(getReplayTool?.inputSchema).toMatchObject({ type: 'object' }); - - const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay'); - expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' }); - - const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); - expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); - expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); - expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); - - const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); - expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); - - const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); - expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); - - const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); - expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' }); - expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); - - const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact'); - expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' }); - - const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts'); - expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' }); - - const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact'); - expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); - - const queryTool = tools.find((tool) => tool.name === 'browser_query'); - expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ - type: 'array', - }); - - for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) { - expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({ - type: 'integer', - minimum: 0, - }); - } - }); - - it('marks the selected page in browser_get_session_info', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, - ], - [ - { - jsonrpc: '2.0', - id: 801, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 802, - method: 'tools/call', - params: { name: 'browser_get_session_info', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 802)); - expect(text).toContain('selected: true'); - expect(text).toContain('1. Docs'); - }); - - it('uses the selected page when pageIndex is omitted', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - visibleText: 'Home text', - }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - visibleText: 'Docs text', - }, - ], - [ - { - jsonrpc: '2.0', - id: 811, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 812, - method: 'tools/call', - params: { name: 'browser_get_visible_text', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 812)); - expect(text).toContain('Docs text'); - }); - - it('opens a page and makes it selected', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 821, - method: 'tools/call', - params: { - name: 'browser_open_page', - arguments: { url: 'https://example.com/new' }, - }, - }, - { - jsonrpc: '2.0', - id: 822, - method: 'tools/call', - params: { name: 'browser_get_session_info', arguments: {} }, - }, - ] - ); - - const openText = getResponseText(responses.find((message) => message.id === 821)); - expect(openText).toContain('status: opened'); - expect(openText).toContain('url: https://example.com/new'); - - const listText = getResponseText(responses.find((message) => message.id === 822)); - expect(listText).toContain('https://example.com/new'); - expect(listText).toContain('selected: true'); - }); - - it('closes the selected page and falls back deterministically', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, - ], - [ - { - jsonrpc: '2.0', - id: 831, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 832, - method: 'tools/call', - params: { name: 'browser_close_page', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 833, - method: 'tools/call', - params: { name: 'browser_get_session_info', arguments: {} }, - }, - ] - ); - - const closeText = getResponseText(responses.find((message) => message.id === 832)); - expect(closeText).toContain('status: closed'); - - const listText = getResponseText(responses.find((message) => message.id === 833)); - expect(listText).toContain('0. Home'); - expect(listText).toContain('selected: true'); - expect(listText).not.toContain('Docs'); - }); - - it('keeps the selected page when closing a different page', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - visibleText: 'Home text', - }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - visibleText: 'Docs text', - }, - { - id: 'page-3', - title: 'Pricing', - currentUrl: 'https://example.com/pricing', - visibleText: 'Pricing text', - }, - ], - [ - { - jsonrpc: '2.0', - id: 834, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, - }, - { - jsonrpc: '2.0', - id: 835, - method: 'tools/call', - params: { name: 'browser_close_page', arguments: { pageId: 'page-3' } }, - }, - { - jsonrpc: '2.0', - id: 836, - method: 'tools/call', - params: { name: 'browser_get_visible_text', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 837, - method: 'tools/call', - params: { name: 'browser_get_session_info', arguments: {} }, - }, - ] - ); - - const visibleText = getResponseText(responses.find((message) => message.id === 836)); - expect(visibleText).toContain('Home text'); - - const listText = getResponseText(responses.find((message) => message.id === 837)); - expect(listText).toContain('0. Home'); - expect(listText).toContain('selected: true'); - expect(listText).not.toContain('Pricing'); - }); - - it('reconciles a stale selected page when browser_get_session_info is called', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 841, - method: 'tools/call', - params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, - }, - { - jsonrpc: '2.0', - id: 842, - method: 'tools/call', - params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, - }, - { - jsonrpc: '2.0', - id: 843, - method: 'tools/call', - params: { name: 'browser_get_session_info', arguments: {} }, - }, - ] - ); - - const listText = getResponseText(responses.find((message) => message.id === 843)); - expect(listText).toContain('0. Home'); - expect(listText).toContain('selected: true'); - }); - - it('closes a page by pageId', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 851, - method: 'tools/call', - params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } }, - }, - { - jsonrpc: '2.0', - id: 852, - method: 'tools/call', - params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, - }, - { - jsonrpc: '2.0', - id: 853, - method: 'tools/call', - params: { name: 'browser_get_session_info', arguments: {} }, - }, - ] - ); - - const closeText = getResponseText(responses.find((message) => message.id === 852)); - expect(closeText).toContain('pageId: page-2'); - expect(closeText).toContain('status: closed'); - - const listText = getResponseText(responses.find((message) => message.id === 853)); - expect(listText).toContain('0. Home'); - expect(listText).not.toContain('https://example.com/new'); - }); - - it('adds an interception rule and lists it by bound pageId', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, - ], - [ - { - jsonrpc: '2.0', - id: 901, - method: 'tools/call', - params: { - name: 'browser_select_page', - arguments: { pageIndex: 1 }, - }, - }, - { - jsonrpc: '2.0', - id: 902, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, - }, - }, - { - jsonrpc: '2.0', - id: 903, - method: 'tools/call', - params: { - name: 'browser_list_intercept_rules', - arguments: {}, - }, - }, - ] - ); - - const listText = getResponseText(responses.find((message) => message.id === 903)); - expect(listText).toContain('pageId: page-2'); - expect(listText).toContain('urlIncludes: /api'); - expect(listText).toContain('method: GET'); - expect(listText).toContain('action: continue'); - }); - - it('keeps an existing rule bound to the original page after selected page changes', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, - ], - [ - { - jsonrpc: '2.0', - id: 911, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, - }, - { - jsonrpc: '2.0', - id: 912, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', method: 'POST', action: 'fail' }, - }, - }, - { - jsonrpc: '2.0', - id: 913, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 914, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - ] - ); - - const listText = getResponseText(responses.find((message) => message.id === 914)); - expect(listText).toContain('pageId: page-1'); - expect(listText).toContain('method: POST'); - expect(listText).toContain('action: fail'); - }); - - it('keeps richer matching rules bound to the original page after selected page changes', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, - ], - [ - { - jsonrpc: '2.0', - id: 1003, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 1004, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - resourceType: 'XHR', - priority: 7, - action: 'continue', - }, - }, - }, - { - jsonrpc: '2.0', - id: 1005, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 0 } }, - }, - { - jsonrpc: '2.0', - id: 1006, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - ] - ); - - const listText = getResponseText(responses.find((message) => message.id === 1006)); - expect(listText).toContain('pageId: page-2'); - expect(listText).toContain('resourceType: XHR'); - expect(listText).toContain('priority: 7'); - }); - - it('removes an interception rule by ruleId', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 921, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, - }, - }, - { - jsonrpc: '2.0', - id: 922, - method: 'tools/call', - params: { - name: 'browser_remove_intercept_rule', - arguments: { ruleId: 'rule-1' }, - }, - }, - { - jsonrpc: '2.0', - id: 923, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - ] - ); - - const removeText = getResponseText(responses.find((message) => message.id === 922)); - expect(removeText).toContain('ruleId: rule-1'); - expect(removeText).toContain('status: removed'); - - const listText = getResponseText(responses.find((message) => message.id === 923)); - expect(listText).toBe('status: empty'); - }); - - it('records recent requests with matched rule action summaries', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-1', - url: 'https://example.com/api/users', - method: 'GET', - resourceType: 'XHR', - }, - { - requestId: 'req-2', - url: 'https://example.com/assets/app.js', - method: 'GET', - resourceType: 'Script', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 931, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', method: 'GET', action: 'fail' }, - }, - }, - { - jsonrpc: '2.0', - id: 932, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 932)); - expect(listText).toContain('requestId: req-1'); - expect(listText).toContain('matchedRuleId: rule-1'); - expect(listText).toContain('action: fail'); - expect(listText).toContain('requestId: req-2'); - expect(listText).toContain('action: continue'); - }); - - it('removes rules and recent requests bound to a page after that page is closed', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - intercept: { - pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 941, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 942, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, - }, - }, - { - jsonrpc: '2.0', - id: 943, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 944, - method: 'tools/call', - params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, - }, - { - jsonrpc: '2.0', - id: 945, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 946, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const preCloseRequestsText = getResponseText(responses.find((message) => message.id === 943)); - expect(preCloseRequestsText).toContain('requestId: req-closed'); - - const listRulesText = getResponseText(responses.find((message) => message.id === 945)); - expect(listRulesText).not.toContain('pageId: page-2'); - - const postCloseRequestsText = getResponseText(responses.find((message) => message.id === 946)); - expect(postCloseRequestsText).not.toContain('requestId: req-closed'); - expect(postCloseRequestsText).not.toContain('pageId: page-2'); - }); - - it('rejects browser_add_intercept_rule when pageIndex and pageId are both provided', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 951, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - pageIndex: 0, - pageId: 'page-1', - urlIncludes: '/api', - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 951); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together'); - }); - - it('rejects browser_add_intercept_rule when action is invalid', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 952, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api', - action: 'mock', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 952); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); - }); - - it('rejects intercept rules when urlPattern and urlRegex are both provided', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 981, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlPattern: 'https://example.com/api/*', - urlRegex: '^https://example\\.com/api/', - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 981); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: urlPattern and urlRegex cannot be used together'); - }); - - it('rejects intercept rules when priority is not an integer', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 982, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api', - priority: 1.5, - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 982); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: priority must be an integer'); - }); - - it('rejects intercept rules when headerMatchers is not an array', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 983, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - headerMatchers: 'x', - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 983); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers must be an array'); - }); - - it('rejects intercept rules when a header matcher is missing name', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 984, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - headerMatchers: [{ valueIncludes: 'staging' }], - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 984); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.name is required'); - }); - - it('rejects intercept rules when a header matcher has no value matcher', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 985, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - headerMatchers: [{ name: 'x-env' }], - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 985); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex'); - }); - - it('rejects intercept rules when no matching condition is provided', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 986, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - priority: 10, - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 986); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: at least one matching condition is required'); - }); - - it('adds a resourceType interception rule and lists its richer matching summary', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 987, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - resourceType: 'XHR', - priority: 10, - action: 'continue', - }, - }, - }, - { - jsonrpc: '2.0', - id: 988, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - ] - ); - - const listText = getResponseText(responses.find((message) => message.id === 988)); - expect(listText).toContain('resourceType: XHR'); - expect(listText).toContain('priority: 10'); - }); - - it('prefers the higher-priority matched rule over an earlier lower-priority rule', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-priority', - url: 'https://example.com/api/orders', - method: 'GET', - resourceType: 'XHR', - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 989, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api', - action: 'fulfill', - statusCode: 201, - body: 'low', - }, - }, - }, - { - jsonrpc: '2.0', - id: 990, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - resourceType: 'XHR', - priority: 10, - action: 'fulfill', - statusCode: 202, - body: 'high', - }, - }, - }, - { - jsonrpc: '2.0', - id: 991, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 991)); - expect(listText).toContain('requestId: req-priority'); - expect(listText).toContain('matchedRuleId: rule-2'); - expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(202); - }); - - it('keeps creation order when matched rules have the same priority', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-same-priority', - url: 'https://example.com/api/orders', - method: 'GET', - resourceType: 'XHR', - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 9911, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api', - priority: 5, - action: 'fulfill', - statusCode: 201, - body: 'first', - }, - }, - }, - { - jsonrpc: '2.0', - id: 9912, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - resourceType: 'XHR', - priority: 5, - action: 'fulfill', - statusCode: 202, - body: 'second', - }, - }, - }, - { - jsonrpc: '2.0', - id: 9913, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 9913)); - expect(listText).toContain('requestId: req-same-priority'); - expect(listText).toContain('matchedRuleId: rule-1'); - expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(201); - }); - - it('matches urlPattern rules with wildcard syntax', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-pattern', - url: 'https://example.com/api/v1/users', - method: 'GET', - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 992, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlPattern: 'https://example.com/api/*', - action: 'fail', - }, - }, - }, - { - jsonrpc: '2.0', - id: 993, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 993)); - expect(listText).toContain('requestId: req-pattern'); - expect(listText).toContain('action: fail'); - expect(pages[0]?.intercept?.failedRequests?.[0]?.requestId).toBe('req-pattern'); - }); - - it('matches urlRegex rules', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-regex', - url: 'https://example.com/api/users', - method: 'GET', - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 994, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlRegex: '^https://example\\.com/api/(users|teams)$', - action: 'continue', - }, - }, - }, - { - jsonrpc: '2.0', - id: 995, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 995)); - expect(listText).toContain('requestId: req-regex'); - expect(listText).toContain('matchedRuleId: rule-1'); - expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-regex'); - }); - - it('matches headerMatchers using valueIncludes and case-insensitive header names', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-header-includes', - url: 'https://example.com/api/header-includes', - method: 'GET', - requestHeaders: { - 'X-Env': 'staging-us', - }, - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 996, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - headerMatchers: [{ name: 'x-env', valueIncludes: 'staging' }], - action: 'fulfill', - statusCode: 207, - body: 'matched-includes', - }, - }, - }, - { - jsonrpc: '2.0', - id: 997, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 997)); - expect(listText).toContain('requestId: req-header-includes'); - expect(listText).toContain('matchedRuleId: rule-1'); - expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseCode).toBe(207); - }); - - it('matches headerMatchers using valueRegex', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-header-regex', - url: 'https://example.com/api/header-regex', - method: 'GET', - requestHeaders: { - 'x-tenant': 'acme-prod', - }, - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 998, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - headerMatchers: [{ name: 'X-Tenant', valueRegex: '^acme-' }], - action: 'continue', - }, - }, - }, - { - jsonrpc: '2.0', - id: 999, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 999)); - expect(listText).toContain('requestId: req-header-regex'); - expect(listText).toContain('matchedRuleId: rule-1'); - expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-header-regex'); - }); - - it('adds a fulfill interception rule and lists its response summary', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 961, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock', - method: 'GET', - action: 'fulfill', - statusCode: 202, - contentType: 'application/json', - body: '{"ok":true}', - }, - }, - }, - { - jsonrpc: '2.0', - id: 962, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - ] - ); - - const listText = getResponseText(responses.find((message) => message.id === 962)); - expect(listText).toContain('action: fulfill'); - expect(listText).toContain('statusCode: 202'); - expect(listText).toContain('contentType: application/json'); - }); - - it('fulfills a paused request with the configured mock response', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-fulfill-1', - url: 'https://example.com/api/mock/users', - method: 'GET', - resourceType: 'XHR', - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 963, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock', - method: 'GET', - action: 'fulfill', - statusCode: 200, - contentType: 'application/json', - body: '{"users":[1]}', - }, - }, - }, - { - jsonrpc: '2.0', - id: 964, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 964)); - expect(listText).toContain('requestId: req-fulfill-1'); - expect(listText).toContain('matchedRuleId: rule-1'); - expect(listText).toContain('action: fulfill'); - expect(pages[0]?.intercept?.fulfilledRequests).toEqual([ - expect.objectContaining({ - requestId: 'req-fulfill-1', - responseCode: 200, - }), - ]); - }); - - it('passes custom response headers to Fetch.fulfillRequest', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { - requestId: 'req-fulfill-2', - url: 'https://example.com/api/mock/headers', - method: 'GET', - }, - ], - }, - }, - ]; - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 965, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock/headers', - action: 'fulfill', - statusCode: 201, - responseHeaders: [ - { name: 'Cache-Control', value: 'no-store' }, - { name: 'X-Mocked-By', value: 'ccs-browser' }, - ], - body: 'ok', - }, - }, - }, - { - jsonrpc: '2.0', - id: 966, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 966)); - expect(listText).toContain('action: fulfill'); - expect(pages[0]?.intercept?.fulfilledRequests?.[0]?.responseHeaders).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'Cache-Control', value: 'no-store' }), - expect.objectContaining({ name: 'X-Mocked-By', value: 'ccs-browser' }), - ]) - ); - }); - - it('allows fulfill rules with an empty response body', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 967, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/empty', - action: 'fulfill', - statusCode: 204, - contentType: 'text/plain', - body: '', - }, - }, - }, - ] - ); - - const addText = getResponseText(responses.find((message) => message.id === 967)); - expect(addText).toContain('action: fulfill'); - expect(addText).toContain('statusCode: 204'); - }); - - it('rejects fulfill rules when statusCode is out of range', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 968, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock', - action: 'fulfill', - statusCode: 99, - body: 'x', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 968); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599'); - }); - - it('rejects fulfill rules when responseHeaders is not an array', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 969, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock', - action: 'fulfill', - responseHeaders: 'x', - body: 'x', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 969); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array'); - }); - - it('rejects fulfill rules when a responseHeaders entry is missing name', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 970, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock', - action: 'fulfill', - responseHeaders: [{ value: 'x' }], - body: 'x', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 970); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required'); - }); - - it('rejects fulfill rules when body is not a string', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 971, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock', - action: 'fulfill', - body: 123, - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 971); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: body must be a string'); - }); - - it('rejects intercept rules when urlRegex is invalid', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1001, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlRegex: '[', - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 1001); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: urlRegex must be a valid regular expression'); - }); - - it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1002, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - headerMatchers: [{ name: 'x-env', valueRegex: '[' }], - action: 'continue', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 1002); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression'); - }); - - it('removes fulfill rules and request summaries after the bound page is closed', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { - id: 'page-2', - title: 'Mocked', - currentUrl: 'https://example.com/mocked', - intercept: { - pausedRequests: [ - { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, - ], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } }, - { - jsonrpc: '2.0', - id: 973, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { - urlIncludes: '/api/mock/close', - action: 'fulfill', - statusCode: 200, - body: 'done', - }, - }, - }, - { jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, - { jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } }, - { jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } }, - { jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } }, - ], - { - responseTimeoutMs: 12000, - } - ); - - expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close'); - expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2'); - expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close'); - }); - - it('returns only the requested number of recent requests', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - pausedRequests: [ - { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, - { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 953, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, - }, - }, - { - jsonrpc: '2.0', - id: 954, - method: 'tools/call', - params: { name: 'browser_list_requests', arguments: { limit: 1 } }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const listText = getResponseText(responses.find((message) => message.id === 954)); - expect(listText).toContain('requestId: req-2'); - expect(listText).not.toContain('requestId: req-1'); - }); - - it('fails browser_add_intercept_rule when Fetch.enable fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - intercept: { - enableError: 'Fetch.enable blocked', - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 955, - method: 'tools/call', - params: { - name: 'browser_add_intercept_rule', - arguments: { urlIncludes: '/api', action: 'continue' }, - }, - }, - { - jsonrpc: '2.0', - id: 956, - method: 'tools/call', - params: { name: 'browser_list_intercept_rules', arguments: {} }, - }, - ], - { - responseTimeoutMs: 12000, - } - ); - - const addResponse = responses.find((message) => message.id === 955); - expect((addResponse?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(addResponse)).toContain('Browser MCP failed: Fetch.enable blocked'); - - const listText = getResponseText(responses.find((message) => message.id === 956)); - expect(listText).toBe('status: empty'); - }); - - it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => { - const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-')); - const installedServerPath = join(installDir, 'ccs-browser-server.cjs'); - const bootstrapServerPath = join(installDir, 'bootstrap.cjs'); - - try { - cpSync(bundledServerPath, installedServerPath); - writeFileSync( - bootstrapServerPath, - 'delete globalThis.WebSocket;\nrequire("./ccs-browser-server.cjs");\n', - 'utf8' - ); - - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Installed Copy', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_get_url_and_title', arguments: {} }, - }, - ], - { - serverPath: bootstrapServerPath, - childEnv: { - NODE_PATH: resolveNodeModulesPath(), - }, - responseTimeoutMs: 12000, - } - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(response)).toContain('title: Installed Copy'); - expect(getResponseText(response)).toContain('url: https://example.com/'); - } finally { - rmSync(installDir, { recursive: true, force: true }); - } - }, 10000); - - it('navigates successfully after readiness polling', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - navigate: { - 'https://example.com/next': { - finalUrl: 'https://example.com/next', - readyStates: ['loading', 'interactive'], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/next' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('pageIndex: 0'); - expect(text).toContain('url: https://example.com/next'); - expect(text).toContain('status: navigated'); - }); - - it('returns a handled error when navigation readiness times out', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - navigate: { - 'https://example.com/slow': { - finalUrl: 'https://example.com/', - readyStates: new Array(60).fill('loading'), - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/slow' }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete'); - }, 8000); - - it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { pageIndex: 1.5, url: 'https://example.com/next' }, - }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { pageIndex: 9, url: 'https://example.com/next' }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'Browser MCP failed: url is required' - ); - expect(getResponseText(responses.find((message) => message.id === 3))).toContain( - 'Browser MCP failed: url must be an absolute http or https URL' - ); - expect(getResponseText(responses.find((message) => message.id === 4))).toContain( - 'Browser MCP failed: pageIndex must be a non-negative integer' - ); - expect(getResponseText(responses.find((message) => message.id === 5))).toContain( - 'page index 9 is out of range' - ); - }); - - it('surfaces Page.navigate CDP failures as handled errors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/start', - navigate: { - 'https://example.com/fail': { - finalUrl: 'https://example.com/start', - errorText: 'net::ERR_ABORTED', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/fail' }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 2); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain( - 'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED' - ); - }); - - it('treats redirects as successful navigation', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/start', - navigate: { - 'https://example.com/redirect': { - finalUrl: 'https://example.com/final', - readyStates: ['loading', 'interactive'], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_navigate', - arguments: { url: 'https://example.com/redirect' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('status: navigated'); - expect(text).toContain('url: https://example.com/final'); - }); - - it('clicks matching elements and reports selector failures', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#submit': {}, - '#disabled': { disabled: true }, - '#throws': { error: 'click exploded' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#submit' } }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#missing' } }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#disabled' } }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#throws' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'status: clicked' - ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'selector: #submit' - ); - - const selectorMiss = responses.find((message) => message.id === 3); - expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(selectorMiss)).toContain( - 'element index 0 is out of range for selector: #missing' - ); - - const disabledError = responses.find((message) => message.id === 4); - expect((disabledError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled'); - - const pageSideError = responses.find((message) => message.id === 5); - expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(pageSideError)).toContain('click exploded'); - }); - - it('clicks the requested zero-based match and rejects out-of-range nth', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Click Page', - currentUrl: 'https://example.com/', - click: { - '.menu-item': [{ label: 'first' }, { label: 'second' }], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 20, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 1 } }, - }, - { - jsonrpc: '2.0', - id: 21, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '.menu-item', nth: 3 } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 20))).toContain( - 'status: clicked' - ); - expect(getResponseText(responses.find((message) => message.id === 20))).toContain('nth: 1'); - expect(getResponseText(responses.find((message) => message.id === 21))).toContain( - 'Browser MCP failed: element index 3 is out of range for selector: .menu-item' - ); - }); - - it('reports detached and hidden click targets as handled errors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#hidden': { hidden: true }, - '#detached': { detached: true }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#hidden' } }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#detached' } }, - }, - ] - ); - - const hiddenError = responses.find((message) => message.id === 2); - expect((hiddenError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(hiddenError)).toContain( - 'element is hidden or not interactable for selector: #hidden' - ); - - const detachedError = responses.find((message) => message.id === 3); - expect((detachedError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(detachedError)).toContain('element is detached for selector: #detached'); - }); - - it('uses a mouse sequence when the target requires it', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#menu-trigger': { requireMouseSequence: true }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#menu-trigger' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'status: clicked' - ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'selector: #menu-trigger' - ); - }); - - it('preserves mouse sequence preparation without dispatching a synthetic click event', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#click-event': { - requireMouseSequence: true, - forbidSyntheticClickEvent: true, - requireNativeClick: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#click-event' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'status: clicked' - ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'selector: #click-event' - ); - }); - - it('preserves native click activation after the mouse sequence', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#native-click': { - requireMouseSequence: true, - requireNativeClick: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#native-click' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #native-click'); - }); - - it('does not force activation when mousedown cancels the interaction', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#cancel-mousedown': { - requireMouseSequence: true, - cancelMouseDown: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#cancel-mousedown' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #cancel-mousedown'); - }); - - it('rechecks connectivity before native activation after the mouse sequence', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#detached-during-click': { - requireMouseSequence: true, - requireNativeClick: true, - detachAfterMouseDown: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#detached-during-click' } }, - }, - ] - ); - - const clickResponse = responses.find((message) => message.id === 2); - expect((clickResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(clickResponse)).toContain('status: clicked'); - expect(getResponseText(clickResponse)).toContain('selector: #detached-during-click'); - }); - - it('falls back to click when mouse sequence dispatch fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - click: { - '#fallback': { mouseSequenceError: 'mouse dispatch exploded' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'browser_click', arguments: { selector: '#fallback' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'status: clicked' - ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'selector: #fallback' - ); - }); - - it('clicks with element-relative offsets and richer click options', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Click Page', - currentUrl: 'https://example.com/', - click: { - '#offset-target': { - requireMouseSequence: true, - expectedOffset: { x: 12, y: 8 }, - expectedButton: 'right', - expectedClickCount: 2, - }, - '#double-left': { - requireMouseSequence: true, - expectedButton: 'left', - expectedClickCount: 2, - requireDoubleClickEvent: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 70, - method: 'tools/call', - params: { - name: 'browser_click', - arguments: { - selector: '#offset-target', - offsetX: 12, - offsetY: 8, - button: 'right', - clickCount: 2, - }, - }, - }, - { - jsonrpc: '2.0', - id: 700, - method: 'tools/call', - params: { - name: 'browser_click', - arguments: { - selector: '#double-left', - clickCount: 2, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 70)); - expect(text).toContain('status: clicked'); - expect(text).toContain('offsetX: 12'); - expect(text).toContain('offsetY: 8'); - expect(text).toContain('button: right'); - expect(text).toContain('clickCount: 2'); - - const doubleText = getResponseText(responses.find((message) => message.id === 700)); - expect(doubleText).toContain('status: clicked'); - expect(doubleText).toContain('button: left'); - expect(doubleText).toContain('clickCount: 2'); - }); - - it('presses a key combination with browser_press_key', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Keyboard Page', - currentUrl: 'https://example.com/', - keyboard: { - expectedKey: 'k', - expectedModifiers: ['Meta'], - expectedRepeat: 2, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 71, - method: 'tools/call', - params: { - name: 'browser_press_key', - arguments: { - key: 'k', - modifiers: ['Meta'], - repeat: 2, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 71)); - expect(text).toContain('status: key-pressed'); - expect(text).toContain('key: k'); - expect(text).toContain('modifiers: Meta'); - expect(text).toContain('repeat: 2'); - }); - - it('supports common special keys with browser_press_key', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Special Key Page', - currentUrl: 'https://example.com/', - keyboard: { - expectedKey: 'Enter', - expectedRepeat: 1, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 711, - method: 'tools/call', - params: { - name: 'browser_press_key', - arguments: { - key: 'Enter', - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 711)); - expect(text).toContain('status: key-pressed'); - expect(text).toContain('key: Enter'); - }); - - it('rejects unsupported modifiers for browser_press_key', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Invalid Modifier Page', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 712, - method: 'tools/call', - params: { - name: 'browser_press_key', - arguments: { - key: 'k', - modifiers: ['Cmd'], - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 712); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain( - 'modifiers must only contain: Alt, Control, Meta, Shift' - ); - }); - - it('scrolls an element into view with browser_scroll', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Scroll Page', - currentUrl: 'https://example.com/', - scroll: { - '#results': { - expectedBehavior: 'into-view', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 72, - method: 'tools/call', - params: { - name: 'browser_scroll', - arguments: { - selector: '#results', - behavior: 'into-view', - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 72)); - expect(text).toContain('status: scrolled'); - expect(text).toContain('selector: #results'); - expect(text).toContain('behavior: into-view'); - }); - - it('scrolls an iframe page by offset with browser_scroll', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Iframe Scroll Page', - currentUrl: 'https://example.com/', - frames: [ - { - selector: '#preview-frame', - query: { - '#preview-frame': { - exists: true, - }, - }, - }, - ], - }, - ], - [ - { - jsonrpc: '2.0', - id: 73, - method: 'tools/call', - params: { - name: 'browser_scroll', - arguments: { - frameSelector: '#preview-frame', - behavior: 'by-offset', - deltaX: 5, - deltaY: 40, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 73)); - expect(text).toContain('status: scrolled'); - expect(text).toContain('behavior: by-offset'); - expect(text).toContain('deltaX: 5'); - expect(text).toContain('deltaY: 40'); - }); - - it('requires real mouse movement for hover-only targets and reports hover failures', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Hover Page', - currentUrl: 'https://example.com/', - hover: { - '.draft-card': { requireCdpMouseMove: true }, - '.missing-bounds': { zeroSized: true }, - }, - query: { - '.draft-card': { - exists: true, - connected: true, - rect: { - x: 100, - y: 40, - width: 120, - height: 48, - top: 40, - right: 220, - bottom: 88, - left: 100, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '.missing-bounds': { - exists: true, - connected: true, - rect: { - x: 0, - y: 0, - width: 0, - height: 0, - top: 0, - right: 0, - bottom: 0, - left: 0, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_hover', - arguments: { selector: '.draft-card' }, - }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'browser_hover', - arguments: { selector: '.missing-bounds' }, - }, - }, - ] - ); - - const hoverResponse = responses.find((message) => message.id === 2); - expect((hoverResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(hoverResponse)).toContain('status: hovered'); - expect(getResponseText(hoverResponse)).toContain('selector: .draft-card'); - - const hoverError = responses.find((message) => message.id === 3); - expect((hoverError?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(hoverError)).toContain( - 'element is hidden or not interactable for selector: .missing-bounds' - ); - }); - - it('queries element diagnostics and validates query fields', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Query Page', - currentUrl: 'https://example.com/', - query: { - '.edit-button': { - exists: true, - connected: true, - innerText: 'Edit', - textContent: 'Edit', - rect: { - x: 123, - y: 45, - width: 48, - height: 24, - top: 45, - right: 171, - bottom: 69, - left: 123, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '.missing-button': { - exists: false, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { selector: '.edit-button' }, - }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { - selector: '.edit-button', - fields: ['display', 'visibility', 'opacity'], - }, - }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { selector: '.missing-button' }, - }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { selector: '.edit-button', fields: 'display' }, - }, - }, - { - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { selector: '.edit-button', fields: ['displayMode'] }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'exists: true' - ); - expect(getResponseText(responses.find((message) => message.id === 2))).toContain( - 'boundingClientRect: {"x":123' - ); - expect(getResponseText(responses.find((message) => message.id === 3))).toContain( - 'display: block' - ); - expect(getResponseText(responses.find((message) => message.id === 3))).not.toContain( - 'innerText:' - ); - - const missingResponse = responses.find((message) => message.id === 4); - expect((missingResponse?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(missingResponse)).toContain('exists: false'); - - expect(getResponseText(responses.find((message) => message.id === 5))).toContain( - 'Browser MCP failed: fields must be an array of strings' - ); - expect(getResponseText(responses.find((message) => message.id === 6))).toContain( - 'Browser MCP failed: unknown query field: displayMode' - ); - }); - - it('reports count, nth-aware fields, href, and onclick for multi-match selectors', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Query Page', - currentUrl: 'https://example.com/', - query: { - '.hover-action': [ - { - exists: true, - connected: true, - innerText: 'Open', - textContent: 'Open', - display: 'block', - visibility: 'visible', - opacity: '1', - href: 'https://example.com/open', - onclick: 'openCard()', - }, - { - exists: true, - connected: true, - innerText: 'Archive', - textContent: 'Archive', - display: 'block', - visibility: 'visible', - opacity: '0.95', - href: 'https://example.com/archive', - onclick: 'archiveCard()', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 30, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { - selector: '.hover-action', - nth: 1, - fields: ['count', 'exists', 'innerText', 'href', 'onclick'], - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 30)); - expect(text).toContain('count: 2'); - expect(text).toContain('exists: true'); - expect(text).toContain('innerText: Archive'); - expect(text).toContain('href: https://example.com/archive'); - expect(text).toContain('onclick: archiveCard()'); - expect(text).toContain('nth: 1'); - }); - - it('keeps count and exists available when nth is out of range, but rejects target fields', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Query Page', - currentUrl: 'https://example.com/', - query: { - '.hover-action': [ - { - exists: true, - connected: true, - innerText: 'Open', - textContent: 'Open', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 31, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { - selector: '.hover-action', - nth: 3, - fields: ['count', 'exists'], - }, - }, - }, - { - jsonrpc: '2.0', - id: 32, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { - selector: '.hover-action', - nth: 3, - fields: ['count', 'innerText'], - }, - }, - }, - ] - ); - - const countOnly = responses.find((message) => message.id === 31); - expect((countOnly?.result as { isError?: boolean }).isError).not.toBe(true); - expect(getResponseText(countOnly)).toContain('count: 1'); - expect(getResponseText(countOnly)).toContain('exists: false'); - - expect(getResponseText(responses.find((message) => message.id === 32))).toContain( - 'Browser MCP failed: element index 3 is out of range for selector: .hover-action' - ); - }); - - it('waits for selector visibility with opacity threshold', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Wait Page', - currentUrl: 'https://example.com/', - wait: { - selectorSnapshots: { - '.hover-action': [ - { exists: false }, - { - exists: true, - connected: true, - display: 'block', - visibility: 'visible', - opacity: '0.95', - rect: { - x: 10, - y: 10, - width: 40, - height: 20, - top: 10, - right: 50, - bottom: 30, - left: 10, - }, - }, - ], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 40, - method: 'tools/call', - params: { - name: 'browser_wait_for', - arguments: { - selector: '.hover-action', - timeoutMs: 1000, - pollIntervalMs: 10, - condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 40))).toContain( - 'status: satisfied' - ); - }); - - it('waits for page text includes and returns timeout summaries', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Wait Timeout', - currentUrl: 'https://example.com/', - wait: { - pageTextSequence: ['loading', 'loading', 'loaded archive menu'], - selectorSnapshots: { - '.hover-action': [ - { - exists: true, - connected: true, - display: 'block', - visibility: 'visible', - opacity: '0.2', - rect: { - x: 10, - y: 10, - width: 40, - height: 20, - top: 10, - right: 50, - bottom: 30, - left: 10, - }, - }, - ], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 41, - method: 'tools/call', - params: { - name: 'browser_wait_for', - arguments: { - timeoutMs: 1000, - pollIntervalMs: 10, - condition: { kind: 'text', includes: 'archive menu' }, - }, - }, - }, - { - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { - name: 'browser_wait_for', - arguments: { - selector: '.hover-action', - timeoutMs: 30, - pollIntervalMs: 10, - condition: { kind: 'visibility', visibility: 'visible', opacityGt: 0.9 }, - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 41))).toContain( - 'status: satisfied' - ); - expect(getResponseText(responses.find((message) => message.id === 42))).toContain( - 'Browser MCP failed: wait condition timed out' - ); - expect(getResponseText(responses.find((message) => message.id === 42))).toContain( - 'opacity=0.2' - ); - }); - - it('waits for selector text includes', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Wait Text', - currentUrl: 'https://example.com/', - wait: { - selectorSnapshots: { - '.hover-action': [ - [ - { - exists: true, - connected: true, - innerText: 'Open', - textContent: 'Open', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - [ - { - exists: true, - connected: true, - innerText: 'Archive', - textContent: 'Archive', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 45, - method: 'tools/call', - params: { - name: 'browser_wait_for', - arguments: { - selector: '.hover-action', - timeoutMs: 1000, - pollIntervalMs: 10, - condition: { kind: 'text', includes: 'Archive' }, - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 45))).toContain( - 'status: satisfied' - ); - }); - - it('lets nth become valid later during polling and rejects unsupported page-level wait kinds', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Wait Nth', - currentUrl: 'https://example.com/', - wait: { - selectorSnapshots: { - '.hover-action': [ - [ - { - exists: true, - connected: true, - innerText: 'Open', - textContent: 'Open', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - [ - { - exists: true, - connected: true, - innerText: 'Open', - textContent: 'Open', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - { - exists: true, - connected: true, - innerText: 'Archive', - textContent: 'Archive', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 10, - y: 10, - width: 40, - height: 20, - top: 10, - right: 50, - bottom: 30, - left: 10, - }, - }, - ], - ], - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 43, - method: 'tools/call', - params: { - name: 'browser_wait_for', - arguments: { - selector: '.hover-action', - nth: 1, - timeoutMs: 1000, - pollIntervalMs: 10, - condition: { kind: 'visibility', visibility: 'visible' }, - }, - }, - }, - { - jsonrpc: '2.0', - id: 44, - method: 'tools/call', - params: { - name: 'browser_wait_for', - arguments: { - timeoutMs: 100, - pollIntervalMs: 10, - condition: { kind: 'visibility', visibility: 'visible' }, - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 43))).toContain( - 'status: satisfied' - ); - expect(getResponseText(responses.find((message) => message.id === 44))).toContain( - 'Browser MCP failed: page-level wait only supports text conditions in Phase 1' - ); - }); - - it('defaults browser_eval to readonly and enforces readwrite gating', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Eval Page', - currentUrl: 'https://example.com/', - eval: { - 'JSON.stringify({ hovered: true, label: "Archive" })': { - result: { hovered: true, label: 'Archive' }, - }, - 'document.body.dataset.test = "1"': { - error: 'EvalError: Possible side-effect in debug-evaluate', - }, - 'throw new Error("boom")': { - error: 'boom', - }, - Infinity: { - unserializableValue: 'Infinity', - }, - window: { - nonSerializable: true, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 50, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'JSON.stringify({ hovered: true, label: "Archive" })' }, - }, - }, - { - jsonrpc: '2.0', - id: 51, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'document.body.dataset.test = "1"' }, - }, - }, - { - jsonrpc: '2.0', - id: 52, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, - }, - }, - { - jsonrpc: '2.0', - id: 53, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'throw new Error("boom")' }, - }, - }, - { - jsonrpc: '2.0', - id: 54, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'Infinity' }, - }, - }, - { - jsonrpc: '2.0', - id: 55, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'window' }, - }, - }, - ], - { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readonly' } } - ); - - expect(getResponseText(responses.find((message) => message.id === 50))).toContain( - 'mode: readonly' - ); - expect(getResponseText(responses.find((message) => message.id === 50))).toContain( - 'value: {"hovered":true,"label":"Archive"}' - ); - expect(getResponseText(responses.find((message) => message.id === 51))).toContain( - 'Browser MCP failed: EvalError: Possible side-effect in debug-evaluate' - ); - expect(getResponseText(responses.find((message) => message.id === 52))).toContain( - 'Browser MCP failed: browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=readonly' - ); - expect(getResponseText(responses.find((message) => message.id === 53))).toContain( - 'Browser MCP failed: boom' - ); - expect(getResponseText(responses.find((message) => message.id === 54))).toContain( - 'value: "Infinity"' - ); - expect(getResponseText(responses.find((message) => message.id === 55))).toContain( - 'Browser MCP failed: evaluation result is not JSON-serializable' - ); - }); - - it('allows readwrite browser_eval when configured', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Eval Page', - currentUrl: 'https://example.com/', - eval: { - 'document.body.dataset.test = "1"': { - result: 'ok', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 54, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: 'document.body.dataset.test = "1"', mode: 'readwrite' }, - }, - }, - ], - { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'readwrite' } } - ); - - expect(getResponseText(responses.find((message) => message.id === 54))).toContain( - 'mode: readwrite' - ); - expect(getResponseText(responses.find((message) => message.id === 54))).toContain( - 'value: "ok"' - ); - }); - - it('hides browser_eval when eval mode is disabled', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Eval Disabled Page', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 56, - method: 'tools/list', - params: {}, - }, - { - jsonrpc: '2.0', - id: 57, - method: 'tools/call', - params: { - name: 'browser_eval', - arguments: { expression: '1 + 1' }, - }, - }, - ], - { childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'disabled' } } - ); - - const tools = (responses.find((message) => message.id === 56)?.result as { tools?: Array<{ name?: string }> }) - ?.tools || []; - expect(tools.some((tool) => tool.name === 'browser_eval')).toBe(false); - expect((responses.find((message) => message.id === 57)?.error as { message?: string })?.message).toBe( - 'Unknown tool: browser_eval' - ); - }); - - it('queries inside an iframe selected by frameSelector', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Frame Page', - currentUrl: 'https://example.com/', - frames: [ - { - selector: '#details-frame', - query: { - '.save-button': { - exists: true, - connected: true, - innerText: 'Save', - textContent: 'Save', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - }, - ], - [ - { - jsonrpc: '2.0', - id: 55, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { - selector: '.save-button', - frameSelector: '#details-frame', - fields: ['exists', 'innerText'], - }, - }, - }, - ] - ); - - expect( - getMockFrame( - { - frames: [{ selector: '#details-frame' }], - id: 'x', - title: 'x', - currentUrl: 'https://example.com/', - }, - '#details-frame' - ) - ).toBeDefined(); - expect(getResponseText(responses.find((message) => message.id === 55))).toContain( - 'innerText: Save' - ); - }); - - it('queries through open shadow roots when pierceShadow is true', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Shadow Page', - currentUrl: 'https://example.com/', - shadowRoots: [ - { - hostSelector: 'app-toolbar', - query: { - 'button[action="archive"]': { - exists: true, - connected: true, - innerText: 'Archive', - textContent: 'Archive', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - }, - ], - [ - { - jsonrpc: '2.0', - id: 56, - method: 'tools/call', - params: { - name: 'browser_query', - arguments: { - selector: 'button[action="archive"]', - pierceShadow: true, - fields: ['exists', 'innerText'], - }, - }, - }, - ] - ); - - expect( - getMockShadowRoot({ - shadowRoots: [{ hostSelector: 'app-toolbar' }], - id: 'x', - title: 'x', - currentUrl: 'https://example.com/', - }) - ).toBeDefined(); - expect(getResponseText(responses.find((message) => message.id === 56))).toContain( - 'innerText: Archive' - ); - }); - - it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Reports', - currentUrl: 'https://example.com/reports', - browser: {}, - events: { - downloads: [ - { - guid: 'download-guid-1', - url: 'https://example.com/files/report.csv', - suggestedFilename: 'report.csv', - progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }], - }, - ], - }, - }, - ]; - - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 57, - method: 'tools/call', - params: { - name: 'browser_set_download_behavior', - arguments: { behavior: 'accept', eventsEnabled: true }, - }, - }, - { - jsonrpc: '2.0', - id: 58, - method: 'tools/call', - params: { name: 'browser_list_downloads', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 59, - method: 'tools/call', - params: { - name: 'browser_cancel_download', - arguments: { guid: 'download-guid-1' }, - }, - }, - { - jsonrpc: '2.0', - id: 60, - method: 'tools/call', - params: { name: 'browser_list_downloads', arguments: {} }, - }, - ], - { responseTimeoutMs: 12000 } - ); - - expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); - expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); - expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); - expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); - expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); - }); - - it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }], - [ - { - jsonrpc: '2.0', - id: 61, - method: 'tools/call', - params: { - name: 'browser_set_download_behavior', - arguments: { - behavior: 'deny', - downloadPath: '/tmp/blocked-downloads', - }, - }, - }, - ] - ); - - const response = responses.find((message) => message.id === 61); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain( - 'Browser MCP failed: downloadPath is only allowed when behavior=accept' - ); - }); - - it('rejects browser_cancel_download for completed downloads', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Reports', - currentUrl: 'https://example.com/reports', - browser: {}, - events: { - downloads: [ - { - guid: 'download-guid-complete', - url: 'https://example.com/files/report.csv', - suggestedFilename: 'report.csv', - progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }], - }, - ], - }, - }, - ]; - - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 610, - method: 'tools/call', - params: { - name: 'browser_set_download_behavior', - arguments: { behavior: 'accept', eventsEnabled: true }, - }, - }, - { - jsonrpc: '2.0', - id: 611, - method: 'tools/call', - params: { name: 'browser_list_downloads', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 612, - method: 'tools/call', - params: { - name: 'browser_cancel_download', - arguments: { guid: 'download-guid-complete' }, - }, - }, - ], - { responseTimeoutMs: 12000 } - ); - - expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); - const response = responses.find((message) => message.id === 612); - expect((response?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(response)).toContain( - 'Browser MCP failed: download is not cancelable in status: completed' - ); - expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined(); - }); - - it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Event Page', - currentUrl: 'https://example.com/', - events: { - downloads: [ - { - guid: 'download-guid-2', - url: 'https://example.com/files/export.zip', - suggestedFilename: 'export.zip', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 62, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' }, - }, - }, - }, - ], - { responseTimeoutMs: 12000 } - ); - - expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); - }); - - it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); - const invoicePath = join(tempDir, 'invoice.pdf'); - const receiptPath = join(tempDir, 'receipt.png'); - writeFileSync(invoicePath, 'invoice'); - writeFileSync(receiptPath, 'receipt'); - - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Root Uploads', - currentUrl: 'https://example.com/root', - fileInputs: { - '#root-upload': { kind: 'file', multiple: true }, - }, - frames: [ - { - selector: '#upload-frame', - fileInputs: { - '#frame-upload': { kind: 'file', multiple: true }, - }, - }, - ], - shadowRoots: [ - { - hostSelector: 'upload-panel', - fileInputs: { - '#shadow-upload': { kind: 'file' }, - }, - }, - ], - }, - { - id: 'page-2', - title: 'Selected Uploads', - currentUrl: 'https://example.com/selected', - fileInputs: { - '#selected-upload': { kind: 'file', multiple: true }, - }, - }, - ]; - - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 63, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, - }, - { - jsonrpc: '2.0', - id: 64, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 65, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { - pageIndex: 0, - selector: '#frame-upload', - files: [invoicePath], - frameSelector: '#upload-frame', - }, - }, - }, - { - jsonrpc: '2.0', - id: 66, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { - pageIndex: 0, - selector: '#shadow-upload', - files: [receiptPath], - pierceShadow: true, - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); - expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); - - expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ - invoicePath, - receiptPath, - ]); - expect( - (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles - ).toEqual([invoicePath]); - expect( - (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles - ).toEqual([receiptPath]); - }); - - it('uses pageId for browser_set_file_input when provided', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); - const assetPath = join(tempDir, 'asset.txt'); - writeFileSync(assetPath, 'asset'); - - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Selected Uploads', - currentUrl: 'https://example.com/selected', - fileInputs: { - '#selected-upload': { kind: 'file' }, - }, - }, - { - id: 'page-2', - title: 'Explicit Uploads', - currentUrl: 'https://example.com/explicit', - fileInputs: { - '#pageid-upload': { kind: 'file' }, - }, - }, - ]; - - const responses = await runMcpRequests( - pages, - [ - { - jsonrpc: '2.0', - id: 67, - method: 'tools/call', - params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } }, - }, - { - jsonrpc: '2.0', - id: 68, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); - expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ - assetPath, - ]); - expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); - }); - - it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-')); - const okPath = join(tempDir, 'ok.txt'); - const missingPath = join(tempDir, 'missing.txt'); - writeFileSync(okPath, 'ok'); - - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Upload Errors', - currentUrl: 'https://example.com/', - fileInputs: { - '#real-file-input': { kind: 'file' }, - '#not-file-input': { kind: 'nonfile' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 69, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { selector: '#not-file-input', files: [okPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 70, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { selector: '#real-file-input', files: [missingPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 71, - method: 'tools/call', - params: { - name: 'browser_set_file_input', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 69))).toContain( - 'element is not a file input for selector: #not-file-input' - ); - expect(getResponseText(responses.find((message) => message.id === 70))).toContain( - `file does not exist: ${missingPath}` - ); - expect(getResponseText(responses.find((message) => message.id === 71))).toContain( - 'pageIndex and pageId cannot be used together' - ); - }); - - it('starts, stops, reads, and clears a recording session', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Recording Page', - currentUrl: 'https://example.com/recording', - recording: { - events: [ - { - kind: 'click', - selector: '#submit', - button: 'left', - clickCount: 1, - offsetX: 12, - offsetY: 8, - timestamp: 1710000000000, - }, - ], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1001))).toContain('status: recording'); - expect(getResponseText(responses.find((message) => message.id === 1002))).toContain('status: stopped'); - expect(getResponseText(responses.find((message) => message.id === 1003))).toContain('type: click'); - expect(getResponseText(responses.find((message) => message.id === 1004))).toContain('status: cleared'); - }); - - it('rejects invalid recording lifecycle operations', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Recording Page', - currentUrl: 'https://example.com/recording', - }, - ], - [ - { jsonrpc: '2.0', id: 1011, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1012, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1013, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1014, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1015, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1016, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1012))).toContain('recording already active'); - expect(getResponseText(responses.find((message) => message.id === 1014))).toContain('no active recording'); - expect(getResponseText(responses.find((message) => message.id === 1016))).toContain('no recording available'); - }); - - it('routes recording start by pageId and rejects page conflicts', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'First', currentUrl: 'https://example.com/1' }, - { id: 'page-2', title: 'Second', currentUrl: 'https://example.com/2' }, - ], - [ - { - jsonrpc: '2.0', - id: 1017, - method: 'tools/call', - params: { name: 'browser_start_recording', arguments: { pageId: 'page-2' } }, - }, - { - jsonrpc: '2.0', - id: 1018, - method: 'tools/call', - params: { name: 'browser_clear_recording', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1019, - method: 'tools/call', - params: { name: 'browser_start_recording', arguments: { pageIndex: 0, pageId: 'page-1' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1017))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together'); - }); - - it('cleans up recording state when stop finalization fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Broken Stop Recording Page', - currentUrl: 'https://example.com/broken-stop-recording', - recording: { - finalizeError: 'recording finalize failed', - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1019_1, - method: 'tools/call', - params: { name: 'browser_start_recording', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1019_2, - method: 'tools/call', - params: { name: 'browser_stop_recording', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1019_3, - method: 'tools/call', - params: { name: 'browser_start_recording', arguments: {} }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain( - 'recording finalize failed' - ); - expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain( - 'status: recording' - ); - }); - - it('rolls back recording state when recorder injection fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Broken Recording Page', - currentUrl: 'https://example.com/broken-recording', - recording: { - injectionError: 'recording injection failed', - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed'); - expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available'); - }); - - it('normalizes type, press_key, scroll, and warnings in a recording result', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Normalize Page', - currentUrl: 'https://example.com/normalize', - recording: { - events: [ - { kind: 'type', selector: '#email', text: 'walker@example.com', timestamp: 1710000000100 }, - { kind: 'press_key', key: 'Enter', modifiers: ['Shift'], timestamp: 1710000000200 }, - { kind: 'scroll', selector: '#results', deltaX: 0, deltaY: 320, timestamp: 1710000000300 }, - ], - warnings: [{ message: 'cross-origin frame events were skipped' }], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1021, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1022, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1023, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1023)); - expect(text).toContain('type: type'); - expect(text).toContain('selector: #email'); - expect(text).toContain('type: press_key'); - expect(text).toContain('type: scroll'); - expect(text).toContain('cross-origin frame events were skipped'); - }); - - it('normalizes drag_element and pointer_action recordings', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Drag Recording Page', - currentUrl: 'https://example.com/drag-recording', - recording: { - events: [ - { - kind: 'drag_element', - selector: '#card-a', - targetSelector: '#lane-b', - timestamp: 1710000000400, - }, - { - kind: 'pointer_action', - actions: [ - { type: 'move', x: 10, y: 20 }, - { type: 'down', button: 'left' }, - { type: 'up', button: 'left' }, - ], - timestamp: 1710000000500, - }, - ], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1031, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1032, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1033, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1033)); - expect(text).toContain('type: drag_element'); - expect(text).toContain('selector: #card-a'); - expect(text).toContain('targetSelector: "#lane-b"'); - expect(text).toContain('type: pointer_action'); - expect(text).toContain('actions:'); - }); - - it('stops recording with a warning when the target page becomes unavailable', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Closing Page', - currentUrl: 'https://example.com/closing', - recording: { - events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000600 }], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1034, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1035, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-1' } } }, - { jsonrpc: '2.0', id: 1036, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1036)); - expect(text).toContain('status: stopped'); - expect(text).toContain('recording stopped because target page was closed'); - }); - - it('starts a replay, reports progress, and completes basic steps', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Replay Page', - currentUrl: 'https://example.com/replay', - click: { - '#submit': {}, - }, - query: { - '#submit': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - type: { - '#email': { kind: 'input', inputType: 'email', value: '' }, - }, - scroll: { - '#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1101, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [ - createReplayStep({ - type: 'click', - pageId: 'page-1', - selector: '#submit', - nth: 0, - args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 }, - }), - createReplayStep({ - type: 'type', - pageId: 'page-1', - selector: '#email', - nth: 0, - args: { text: 'walker@example.com' }, - }), - createReplayStep({ - type: 'scroll', - pageId: 'page-1', - selector: '#results', - args: { deltaX: 0, deltaY: 240 }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1102, - method: 'tools/call', - params: { name: 'browser_get_replay', arguments: {} }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3'); - expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed'); - }); - - it('rejects invalid replay payloads before execution starts', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }], - [ - { - jsonrpc: '2.0', - id: 1111, - method: 'tools/call', - params: { name: 'browser_start_replay', arguments: { steps: [] } }, - }, - { - jsonrpc: '2.0', - id: 1112, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1113, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })], - pageId: 'page-1', - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array'); - expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type'); - expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch'); - }); - - it('fails replay on the first failing step and reports failedStepIndex', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Replay Failure Page', - currentUrl: 'https://example.com/replay-failure', - click: { - '#submit': {}, - }, - query: { - '#submit': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1114, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [ - createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }), - createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1115, - method: 'tools/call', - params: { name: 'browser_get_replay', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1115)); - expect(text).toContain('status: failed'); - expect(text).toContain('failedStepIndex: 1'); - expect(text).toContain('element index 0 is out of range for selector: #missing'); - }); - - it('cancels a replay before later steps run', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Replay Cancel Page', - currentUrl: 'https://example.com/replay-cancel', - click: { - '#submit': {}, - }, - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 40, - height: 40, - top: 30, - right: 60, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#submit': { - exists: true, - connected: true, - rect: { - x: 120, - y: 30, - width: 100, - height: 40, - top: 30, - right: 220, - bottom: 70, - left: 120, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1116, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [ - createReplayStep({ - type: 'pointer_action', - pageId: 'page-1', - args: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'pause', durationMs: 400 }, - ], - }, - }), - createReplayStep({ - type: 'click', - pageId: 'page-1', - selector: '#submit', - args: {}, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1117, - method: 'tools/call', - params: { name: 'browser_cancel_replay', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1118, - method: 'tools/call', - params: { name: 'browser_get_replay', arguments: {} }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1116))).toContain( - 'status: running' - ); - const text = getResponseText(responses.find((message) => message.id === 1118)); - expect(text).toContain('status: canceled'); - expect(text).not.toContain('completedSteps: 2'); - }); - - it('replays drag_element and pointer_action steps successfully', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Replay Drag Page', - currentUrl: 'https://example.com/replay-drag', - query: { - '#card-a': { - exists: true, - connected: true, - rect: { - x: 20, - y: 40, - width: 100, - height: 60, - top: 40, - right: 120, - bottom: 100, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#lane-b': { - exists: true, - connected: true, - rect: { - x: 240, - y: 60, - width: 120, - height: 80, - top: 60, - right: 360, - bottom: 140, - left: 240, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1121, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [ - createReplayStep({ - type: 'drag_element', - pageId: 'page-1', - selector: '#card-a', - args: { targetSelector: '#lane-b' }, - }), - createReplayStep({ - type: 'pointer_action', - pageId: 'page-1', - args: { - actions: [ - { type: 'move', x: 10, y: 20 }, - { type: 'down', button: 'left' }, - { type: 'up', button: 'left' }, - ], - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2'); - }); - - it('starts orchestration, reports progress, and completes a wait_then_click flow', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Orchestration Page', - currentUrl: 'https://example.com/orchestration', - click: { - '#menu': {}, - }, - wait: { - selectorSnapshots: { - '#menu': [ - [ - { - exists: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - query: { - '#menu': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1201, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { - selector: '#menu', - condition: { kind: 'visibility' }, - timeoutMs: 1000, - }, - click: { - selector: '#menu', - }, - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1202, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1'); - expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); - }); - - it('cancels orchestration before later blocks run', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Orchestration Cancel Page', - currentUrl: 'https://example.com/orchestration-cancel', - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 40, - height: 40, - top: 30, - right: 60, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#status': { - exists: true, - connected: true, - innerText: 'ready', - textContent: 'ready', - rect: { - x: 80, - y: 30, - width: 80, - height: 20, - top: 30, - right: 160, - bottom: 50, - left: 80, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1202_1, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'run_replay_sequence', - args: { - steps: [ - createReplayStep({ - type: 'pointer_action', - pageId: 'page-1', - args: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'pause', durationMs: 400 }, - ], - }, - }), - ], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1202_2, - method: 'tools/call', - params: { name: 'browser_cancel_orchestration', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1202_3, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain( - 'status: running' - ); - const text = getResponseText(responses.find((message) => message.id === 1202_3)); - expect(text).toContain('status: canceled'); - expect(text).not.toContain('completedBlocks: 2'); - }); - - it('completes a wait_then_type flow', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Type Orchestration Page', - currentUrl: 'https://example.com/type-orchestration', - wait: { - selectorSnapshots: { - '#email': [ - [ - { - exists: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - type: { - '#email': { kind: 'input', inputType: 'email', value: '' }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1203, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'wait_for_then_type', - args: { - wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - type: { selector: '#email', text: 'walker@example.com', clearFirst: true }, - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1'); - }); - - it('completes a run_replay_sequence block', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Replay Sequence Orchestration Page', - currentUrl: 'https://example.com/orchestration-replay', - click: { '#submit': {} }, - query: { - '#submit': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1211, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'run_replay_sequence', - args: { - steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed'); - }); - - it('completes an assert_query block', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Assert Query Page', - currentUrl: 'https://example.com/assert-query', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready', - textContent: 'ready', - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1212, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assert: { field: 'innerText', equals: 'ready' }, - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed'); - }); - - it('fails orchestration on the first failing block', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }], - [ - { - jsonrpc: '2.0', - id: 1213, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assert: { field: 'exists', equals: 'true' }, - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1214, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1214)); - expect(text).toContain('status: failed'); - expect(text).toContain('failedBlockIndex: 0'); - }); - - it('runs a single-layer sequence with two successful blocks', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Page', - currentUrl: 'https://example.com/sequence', - click: { - '#menu': {}, - }, - wait: { - selectorSnapshots: { - '#menu': [ - [ - { - exists: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - query: { - '#menu': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1401, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - click: { selector: '#menu' }, - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1'); - }); - - it('reports failedSequenceStepIndex when a sequence step fails', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Failure Page', - currentUrl: 'https://example.com/sequence-failure', - click: { - '#menu': {}, - }, - wait: { - selectorSnapshots: { - '#menu': [ - [ - { - exists: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1402, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - click: { selector: '#menu' }, - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - ], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1403, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1403)); - expect(text).toContain('status: failed'); - expect(text).toContain('failedBlockIndex: 0'); - expect(text).toContain('failedSequenceStepIndex: 1'); - }); - - it('rejects empty sequence steps and nested sequence blocks', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }], - [ - { - jsonrpc: '2.0', - id: 1404, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1405, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })], - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array'); - expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks'); - }); - - it('runs a top-level block followed by a sequence in order', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Mixed Sequence Page', - currentUrl: 'https://example.com/mixed-sequence', - click: { - '#menu': {}, - }, - wait: { - selectorSnapshots: { - '#menu': [ - [ - { - exists: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1406, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'wait_for_then_click', - args: { - wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 }, - click: { selector: '#menu' }, - }, - }), - ], - }, - }), - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed'); - expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2'); - }); - - it('passes assert_query with multiple structured assertions', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Assert Query Page', - currentUrl: 'https://example.com/assert-query', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '0.95', - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1301, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['exists', 'innerText', 'opacity'] }, - assertions: [ - { field: 'exists', op: 'equals', value: true }, - { field: 'innerText', op: 'contains', value: 'ready' }, - { field: 'opacity', op: 'gte', value: 0.9 }, - ], - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1301))).toContain('status: completed'); - }); - - it('returns failedAssertionIndex, expected, and actual when assert_query fails', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Assert Failure Page', - currentUrl: 'https://example.com/assert-failure', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'loading', - textContent: 'loading', - display: 'block', - visibility: 'visible', - opacity: '0.42', - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1302, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText', 'opacity'] }, - assertions: [ - { field: 'innerText', op: 'contains', value: 'ready' }, - { field: 'opacity', op: 'gte', value: 0.9 }, - ], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1303, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1303)); - expect(text).toContain('status: failed'); - expect(text).toContain('failedBlockIndex: 0'); - expect(text).toContain('failedAssertionIndex: 0'); - expect(text).toContain('field: innerText'); - expect(text).toContain('expected: "ready"'); - expect(text).toContain('actual: "loading"'); - }); - - it('fails when numeric comparison is used on a non-number-like field or when op is unsupported', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Assert Type Error Page', - currentUrl: 'https://example.com/assert-type-error', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'loading', - textContent: 'loading', - opacity: '0.42', - display: 'block', - visibility: 'visible', - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1304, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'gte', value: 1 }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1305, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['opacity'] }, - assertions: [{ field: 'opacity', op: 'matches', value: '.*' }], - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1304))).toContain('numeric comparison expects a number-like field: innerText'); - expect(getResponseText(responses.find((message) => message.id === 1305))).toContain('unsupported assertion operator'); - }); - - it('passes numeric gte and lt assertions', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Numeric Assertion Page', - currentUrl: 'https://example.com/assert-numeric', - query: { - '#status': { - exists: true, - connected: true, - opacity: '0.95', - display: 'block', - visibility: 'visible', - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1306, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['opacity'] }, - assertions: [ - { field: 'opacity', op: 'gte', value: 0.9 }, - { field: 'opacity', op: 'lt', value: 1.0 }, - ], - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1306))).toContain('status: completed'); - }); - - it('continues to the next top-level block when continueOnError is true', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Policy Page', - currentUrl: 'https://example.com/policy', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1501, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - continueOnError: true, - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1502, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1502)); - expect(text).toContain('status: completed_with_failures'); - expect(text).toContain('completedBlocks: 2'); - expect(text).toContain('failedCount: 1'); - expect(text).toContain('failure[0].blockIndex: 0'); - }); - - it('continues inside sequence when a step has continueOnError', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Policy Page', - currentUrl: 'https://example.com/sequence-policy', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1503, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - args: { - steps: [ - createOrchestrationBlock({ - type: 'assert_query', - continueOnError: true, - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1504, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1504)); - expect(text).toContain('status: completed_with_failures'); - expect(text).toContain('completedBlocks: 1'); - expect(text).toContain('failedCount: 1'); - expect(text).toContain('failure[0].sequenceStepIndex: 0'); - }); - - it('still stops immediately when continueOnError is not enabled', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Strict Stop Page', currentUrl: 'https://example.com/strict-stop' }], - [ - { - jsonrpc: '2.0', - id: 1505, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - continueOnError: true, - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1505)); - expect(text).toContain('status: failed'); - expect(text).toContain('completedBlocks: 0'); - }); - - it('continues to the next top-level block when a sequence block itself allows continueOnError', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Sequence Top-Level Policy Page', - currentUrl: 'https://example.com/sequence-top-policy', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 1506, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'sequence', - continueOnError: true, - args: { - steps: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#missing', fields: ['exists'] }, - assertions: [{ field: 'exists', op: 'equals', value: true }], - }, - }), - ], - }, - }), - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1507, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ]); - - const text = getResponseText(responses.find((message) => message.id === 1507)); - expect(text).toContain('status: completed_with_failures'); - expect(text).toContain('completedBlocks: 2'); - expect(text).toContain('failedCount: 1'); - expect(text).toContain('failure[0].sequenceStepIndex: 0'); - }); - - it('exports recording and replay artifacts and lists them', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Artifact Page', - currentUrl: 'https://example.com/artifact', - recording: { - events: [ - { - kind: 'click', - selector: '#submit', - timestamp: 1710000000000, - }, - ], - }, - click: { - '#submit': {}, - }, - query: { - '#submit': { - exists: true, - connected: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { - jsonrpc: '2.0', - id: 1603, - method: 'tools/call', - params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1604, - method: 'tools/call', - params: { - name: 'browser_start_replay', - arguments: { - steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1605, - method: 'tools/call', - params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1606, - method: 'tools/call', - params: { name: 'browser_list_artifacts', arguments: {} }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported'); - expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported'); - const listText = getResponseText(responses.find((message) => message.id === 1606)); - expect(listText).toContain('name: rec-smoke'); - expect(listText).toContain('name: replay-smoke'); - }); - - it('imports and deletes orchestration artifacts', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Artifact Page', - currentUrl: 'https://example.com/artifact', - click: { '#submit': {} }, - wait: { - selectorSnapshots: { - '#submit': [ - [ - { - exists: true, - rect: { - x: 20, - y: 30, - width: 100, - height: 40, - top: 30, - right: 120, - bottom: 70, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - ], - ], - }, - }, - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1607, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1608, - method: 'tools/call', - params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1609, - method: 'tools/call', - params: { name: 'browser_list_artifacts', arguments: {} }, - }, - { - jsonrpc: '2.0', - id: 1610, - method: 'tools/call', - params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } }, - }, - { - jsonrpc: '2.0', - id: 1611, - method: 'tools/call', - params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported'); - expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported'); - expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted'); - }); - - it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-')); - const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json'); - writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a')); - - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Artifact Failure Page', - currentUrl: 'https://example.com/artifact-failure', - recording: { - events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }], - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, - { jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, - { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, - { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, - { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, - { jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } }, - { jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } }, - { jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); - expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); - expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); - expect(getResponseText(responses.find((message) => message.id === 1618))).toContain( - 'artifact name must start with a letter or number' - ); - expect(getResponseText(responses.find((message) => message.id === 1619))).toContain( - 'artifact name must start with a letter or number' - ); - expect(getResponseText(responses.find((message) => message.id === 1620))).toContain( - 'artifact file exceeds maximum size' - ); - }); - - it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Home', - currentUrl: 'https://example.com/', - }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 1701, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageIndex: 1 }, - run: { - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }, - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1701))).toContain('status: completed'); - }); - - it('runs open_page_then_run and executes the inner block on the newly opened page', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1702, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'open_page_then_run', - args: { - open: { url: 'https://example.com/docs' }, - run: { - type: 'wait_for_then_press_key', - args: { - wait: { condition: { kind: 'text', includes: 'New page visible text' }, timeoutMs: 1000 }, - pressKey: { key: 'Enter' }, - }, - }, - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1702))).toContain('status: completed'); - }); - - it('runs close_page_then_continue and then continues with the remaining blocks', async () => { - const responses = await runMcpRequests( - [ - { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { - id: 'page-2', - title: 'Docs', - currentUrl: 'https://example.com/docs', - query: { - '#status': { - exists: true, - connected: true, - innerText: 'ready state', - textContent: 'ready state', - display: 'block', - visibility: 'visible', - opacity: '1', - rect: { - x: 20, - y: 80, - width: 100, - height: 40, - top: 80, - right: 120, - bottom: 120, - left: 20, - }, - }, - }, - }, - ], - [ - { jsonrpc: '2.0', id: 1703, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageId: 'page-2' } } }, - { - jsonrpc: '2.0', - id: 1704, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'close_page_then_continue', - args: { close: { pageId: 'page-2' } }, - }), - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageIndex: 0 }, - run: { - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }, - }, - continueOnError: true, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1704))).toContain('completedBlocks: 2'); - }); - - it('rejects missing run payloads and nested cross-page blocks', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1705, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [createOrchestrationBlock({ type: 'select_page_then_run', args: { select: { pageIndex: 0 } } })], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1706, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageIndex: 0 }, - run: createOrchestrationBlock({ - type: 'open_page_then_run', - args: { open: { url: 'https://example.com' }, run: { type: 'assert_query', args: {} } }, - }), - }, - }), - ], - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 1705))).toContain('cross-page run block is required'); - expect(getResponseText(responses.find((message) => message.id === 1706))).toContain('nested cross-page blocks are not supported'); - }); - - it('fails when select_page_then_run targets a missing page', async () => { - const responses = await runMcpRequests( - [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], - [ - { - jsonrpc: '2.0', - id: 1707, - method: 'tools/call', - params: { - name: 'browser_start_orchestration', - arguments: { - blocks: [ - createOrchestrationBlock({ - type: 'select_page_then_run', - args: { - select: { pageId: 'page-9' }, - run: { - type: 'assert_query', - args: { - query: { selector: '#status', fields: ['innerText'] }, - assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], - }, - }, - }, - }), - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 1708, - method: 'tools/call', - params: { name: 'browser_get_orchestration', arguments: {} }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 1708)); - expect(text).toContain('status: failed'); - expect(text).toContain('page not found: page-9'); - }); - - it('drags local files onto normal, frame, and shadow dropzones', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); - const invoicePath = join(tempDir, 'invoice.pdf'); - const receiptPath = join(tempDir, 'receipt.png'); - writeFileSync(invoicePath, 'invoice'); - writeFileSync(receiptPath, 'receipt'); - - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Uploads', - currentUrl: 'https://example.com/uploads', - dropzones: { - '#dropzone': { accepted: true }, - '#cancel-dropzone': { acceptedByCancel: true }, - }, - frames: [ - { - selector: '#upload-frame', - dropzones: { - '#frame-dropzone': { accepted: true }, - }, - }, - ], - shadowRoots: [ - { - hostSelector: 'upload-panel', - dropzones: { - '#shadow-dropzone': { accepted: true }, - }, - }, - ], - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 901, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 902, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#frame-dropzone', - files: [invoicePath], - frameSelector: '#upload-frame', - }, - }, - }, - { - jsonrpc: '2.0', - id: 903, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#shadow-dropzone', - files: [receiptPath], - pierceShadow: true, - }, - }, - }, - { - jsonrpc: '2.0', - id: 904, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { - selector: '#cancel-dropzone', - files: [invoicePath], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); - expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ - 'dragenter', - 'dragover', - 'drop', - ]); - expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedFiles).toEqual([ - { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, - { name: 'receipt.png', size: 7, type: 'image/png' }, - ]); - expect( - getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles - ).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]); - expect( - getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles - ).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]); - expect(getResponseText(responses.find((message) => message.id === 904))).toContain( - 'status: files-dropped' - ); - expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([ - { name: 'invoice.pdf', size: 7, type: 'application/pdf' }, - ]); - }); - - it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); - const okPath = join(tempDir, 'ok.txt'); - const missingPath = join(tempDir, 'missing.txt'); - writeFileSync(okPath, 'ok'); - - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Uploads', - currentUrl: 'https://example.com/uploads', - dropzones: { - '#reject-dropzone': { accepted: false }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 904, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { selector: '#reject-dropzone', files: [missingPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 905, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 906, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { selector: '#missing-dropzone', files: [okPath] }, - }, - }, - { - jsonrpc: '2.0', - id: 907, - method: 'tools/call', - params: { - name: 'browser_drag_files', - arguments: { selector: '#reject-dropzone', files: [okPath] }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); - expect(getResponseText(responses.find((message) => message.id === 905))).toContain( - 'pageIndex and pageId cannot be used together' - ); - expect(getResponseText(responses.find((message) => message.id === 906))).toContain( - 'element not found for selector: #missing-dropzone' - ); - expect(getResponseText(responses.find((message) => message.id === 907))).toContain( - 'drop target rejected files' - ); - }); - - it('drags an element to another element and to explicit coordinates', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Drag Page', - currentUrl: 'https://example.com/drag', - query: { - '#card-a': { - exists: true, - connected: true, - rect: { - x: 20, - y: 40, - width: 100, - height: 60, - top: 40, - right: 120, - bottom: 100, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#lane-b': { - exists: true, - connected: true, - rect: { - x: 240, - y: 60, - width: 120, - height: 80, - top: 60, - right: 360, - bottom: 140, - left: 240, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - { - id: 'page-2', - title: 'Second Drag Page', - currentUrl: 'https://example.com/drag-2', - query: { - '#card-a': { - exists: true, - connected: true, - rect: { - x: 10, - y: 20, - width: 40, - height: 20, - top: 20, - right: 50, - bottom: 40, - left: 10, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '#lane-b': { - exists: true, - connected: true, - rect: { - x: 110, - y: 80, - width: 60, - height: 30, - top: 80, - right: 170, - bottom: 110, - left: 110, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 908, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { selector: '#card-a', targetSelector: '#lane-b', steps: 3 }, - }, - }, - { - jsonrpc: '2.0', - id: 909, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { selector: '#card-a', targetX: 420, targetY: 180, steps: 2 }, - }, - }, - { - jsonrpc: '2.0', - id: 916, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { pageId: 'page-2', selector: '#card-a', targetSelector: '#lane-b', steps: 1 }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged'); - expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b'); - expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged'); - expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420'); - expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180'); - expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b'); - expect(pages[0]!.drag?.recordedActions).toEqual([ - { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, - { type: 'mousePressed', x: 70, y: 70, button: 'left' }, - { type: 'mouseMoved', x: 147, y: 80, button: 'left' }, - { type: 'mouseMoved', x: 223, y: 90, button: 'left' }, - { type: 'mouseMoved', x: 300, y: 100, button: 'left' }, - { type: 'mouseReleased', x: 300, y: 100, button: 'left' }, - { type: 'mouseMoved', x: 70, y: 70, button: 'none' }, - { type: 'mousePressed', x: 70, y: 70, button: 'left' }, - { type: 'mouseMoved', x: 245, y: 125, button: 'left' }, - { type: 'mouseMoved', x: 420, y: 180, button: 'left' }, - { type: 'mouseReleased', x: 420, y: 180, button: 'left' }, - ]); - expect(pages[1]!.drag?.recordedActions).toEqual([ - { type: 'mouseMoved', x: 30, y: 30, button: 'none' }, - { type: 'mousePressed', x: 30, y: 30, button: 'left' }, - { type: 'mouseMoved', x: 140, y: 95, button: 'left' }, - { type: 'mouseReleased', x: 140, y: 95, button: 'left' }, - ]); - }); - - it('rejects invalid browser_drag_element target combinations, page conflicts, and missing elements', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Drag Failures', - currentUrl: 'https://example.com/drag', - query: { - '#card-a': { - exists: true, - connected: true, - rect: { - x: 20, - y: 40, - width: 100, - height: 60, - top: 40, - right: 120, - bottom: 100, - left: 20, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 910, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, - }, - }, - { - jsonrpc: '2.0', - id: 911, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { selector: '#missing-source', targetX: 400, targetY: 200 }, - }, - }, - { - jsonrpc: '2.0', - id: 912, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { selector: '#card-a', targetSelector: '#missing-target' }, - }, - }, - { - jsonrpc: '2.0', - id: 917, - method: 'tools/call', - params: { - name: 'browser_drag_element', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 910))).toContain( - 'targetSelector and targetX/targetY cannot be used together' - ); - expect(getResponseText(responses.find((message) => message.id === 911))).toContain( - 'source element not found' - ); - expect(getResponseText(responses.find((message) => message.id === 912))).toContain( - 'target element not found' - ); - expect(getResponseText(responses.find((message) => message.id === 917))).toContain( - 'pageIndex and pageId cannot be used together' - ); - }); - - it('runs minimal pointer sequences with browser_pointer_action', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Pointer Page', - currentUrl: 'https://example.com/pointer', - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 30, - y: 50, - width: 40, - height: 20, - top: 50, - right: 70, - bottom: 70, - left: 30, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - { - id: 'page-2', - title: 'Second Pointer Page', - currentUrl: 'https://example.com/pointer-2', - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 100, - y: 200, - width: 20, - height: 20, - top: 200, - right: 120, - bottom: 220, - left: 100, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 913, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'down' }, - { type: 'move', x: 220, y: 160 }, - { type: 'pause', durationMs: 5 }, - { type: 'up' }, - ], - }, - }, - }, - { - jsonrpc: '2.0', - id: 918, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - pageId: 'page-2', - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'down' }, - { type: 'up' }, - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed'); - expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1'); - expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed'); - expect(pages[0]!.drag?.recordedActions).toEqual([ - { type: 'mouseMoved', x: 50, y: 60, button: 'none' }, - { type: 'mousePressed', x: 50, y: 60, button: 'left' }, - { type: 'mouseMoved', x: 220, y: 160, button: 'left' }, - { type: 'mouseReleased', x: 220, y: 160, button: 'left' }, - ]); - expect(pages[1]!.drag?.recordedActions).toEqual([ - { type: 'mouseMoved', x: 110, y: 210, button: 'none' }, - { type: 'mousePressed', x: 110, y: 210, button: 'left' }, - { type: 'mouseReleased', x: 110, y: 210, button: 'left' }, - ]); - }); - - it('rejects invalid browser_pointer_action sequences and page conflicts', async () => { - const pages: MockPageState[] = [ - { - id: 'page-1', - title: 'Pointer Failures', - currentUrl: 'https://example.com/pointer', - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 30, - y: 50, - width: 40, - height: 20, - top: 50, - right: 70, - bottom: 70, - left: 30, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ]; - - const responses = await runMcpRequests(pages, [ - { - jsonrpc: '2.0', - id: 914, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [{ type: 'up' }], - }, - }, - }, - { - jsonrpc: '2.0', - id: 915, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [{ type: 'move', selector: '#missing-handle' }], - }, - }, - }, - { - jsonrpc: '2.0', - id: 919, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - pageIndex: 0, - pageId: 'page-1', - actions: [{ type: 'move', x: 10, y: 10 }], - }, - }, - }, - { - jsonrpc: '2.0', - id: 920, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'down', button: 'left' }, - { type: 'up', button: 'right' }, - ], - }, - }, - }, - ]); - - expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error'); - expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable'); - expect(getResponseText(responses.find((message) => message.id === 919))).toContain( - 'pageIndex and pageId cannot be used together' - ); - expect(getResponseText(responses.find((message) => message.id === 920))).toContain( - 'pointer state error' - ); - }); - - it('rejects mutating browser tools when the selected page becomes stale', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-')); - const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs'); - writeFileSync( - patchedServerPath, - readFileSync(bundledServerPath, 'utf8').replace( - "let selectedPageId = '';", - "let selectedPageId = 'page-2';" - ) - ); - - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Primary Page', - currentUrl: 'https://example.com/primary', - query: { - '#handle': { - exists: true, - connected: true, - rect: { - x: 30, - y: 50, - width: 40, - height: 20, - top: 50, - right: 70, - bottom: 70, - left: 30, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 923, - method: 'tools/call', - params: { - name: 'browser_pointer_action', - arguments: { - actions: [{ type: 'move', selector: '#handle' }], - }, - }, - }, - ], - { serverPath: patchedServerPath } - ); - - expect(getResponseText(responses.find((message) => message.id === 923))).toContain( - 'Selected page is no longer available; specify pageIndex or pageId explicitly.' - ); - }); - - it('waits for a matching navigation event with browser_wait_for_event', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Event Page', - currentUrl: 'https://example.com/', - events: { - navigations: [{ url: 'https://example.com/checkout' }], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 69, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - timeoutMs: 1000, - event: { kind: 'navigation', urlIncludes: '/checkout' }, - }, - }, - }, - ] - ); - - expect(getResponseText(responses.find((message) => message.id === 69))).toContain( - 'status: observed' - ); - }); - - it('ignores child-frame navigations when waiting for a page navigation event', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Event Page', - currentUrl: 'https://example.com/', - events: { - navigations: [ - { url: 'https://example.com/embedded-checkout', parentId: 'frame-1' }, - { url: 'https://example.com/checkout' }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 58, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - timeoutMs: 1000, - event: { kind: 'navigation', urlIncludes: '/checkout' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 58)); - expect(text).toContain('status: observed'); - expect(text).toContain('"url":"https://example.com/checkout"'); - expect(text).not.toContain('embedded-checkout'); - }); - - it('filters download events by the selected page frame when pageIndex is provided', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'First Page', - currentUrl: 'https://example.com/one', - events: { - downloads: [ - { - url: 'https://example.com/first-report.csv', - suggestedFilename: 'first-report.csv', - frameId: 'frame-page-1', - }, - ], - }, - }, - { - id: 'page-2', - title: 'Second Page', - currentUrl: 'https://example.com/two', - events: { - downloads: [ - { - url: 'https://example.com/second-report.csv', - suggestedFilename: 'second-report.csv', - frameId: 'frame-page-2', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 59, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - pageIndex: 1, - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'second-report' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 59)); - expect(text).toContain('status: observed'); - expect(text).toContain('"suggestedFilename":"second-report.csv"'); - expect(text).not.toContain('first-report.csv'); - }); - - it('matches download events from child frames within the selected page', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Framed Page', - currentUrl: 'https://example.com/frame-host', - frameTree: { - frame: { id: 'frame-page-1' }, - childFrames: [{ frame: { id: 'frame-page-1-child' } }], - }, - events: { - downloads: [ - { - url: 'https://example.com/embedded-report.csv', - suggestedFilename: 'embedded-report.csv', - frameId: 'frame-page-1-child', - }, - ], - }, - }, - { - id: 'page-2', - title: 'Other Page', - currentUrl: 'https://example.com/other', - events: { - downloads: [ - { - url: 'https://example.com/other-report.csv', - suggestedFilename: 'other-report.csv', - frameId: 'frame-page-2', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 60, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - pageIndex: 0, - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'embedded-report' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 60)); - expect(text).toContain('status: observed'); - expect(text).toContain('"suggestedFilename":"embedded-report.csv"'); - expect(text).not.toContain('other-report.csv'); - }); - - it('refreshes frame ids when a download comes from a newly attached child frame', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Dynamic Frame Page', - currentUrl: 'https://example.com/dynamic', - frameTreeSequence: [ - { frame: { id: 'frame-page-1' } }, - { - frame: { id: 'frame-page-1' }, - childFrames: [{ frame: { id: 'frame-page-1-late-child' } }], - }, - ], - events: { - downloads: [ - { - url: 'https://example.com/late-report.csv', - suggestedFilename: 'late-report.csv', - frameId: 'frame-page-1-late-child', - }, - ], - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 61, - method: 'tools/call', - params: { - name: 'browser_wait_for_event', - arguments: { - pageIndex: 0, - timeoutMs: 1000, - event: { kind: 'download', suggestedFilenameIncludes: 'late-report' }, - }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 61)); - expect(text).toContain('status: observed'); - expect(text).toContain('"suggestedFilename":"late-report.csv"'); - }); - - it('captures element screenshots using the post-scroll visible clip and reports failures', async () => { - const screenshotPlan: MockPageState['screenshot'] = { - data: 'ZWxlbWVudC1zaG90', - requireScrolledMeasurement: true, - expectedClip: { - x: 0, - y: 12, - width: 50, - height: 28, - scale: 1, - }, - }; - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Element Screenshot Page', - currentUrl: 'https://example.com/', - query: { - '.edit-button': { - exists: true, - connected: true, - innerText: 'Edit', - textContent: 'Edit', - rect: { - x: -14, - y: 12, - width: 64, - height: 28, - top: 12, - right: 50, - bottom: 40, - left: -14, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '.zero-sized': { - exists: true, - connected: true, - rect: { - x: 10, - y: 10, - width: 0, - height: 0, - top: 10, - right: 10, - bottom: 10, - left: 10, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - screenshot: screenshotPlan, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_element_screenshot', - arguments: { selector: '.edit-button' }, - }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'browser_take_element_screenshot', - arguments: { selector: '.missing-button' }, - }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'browser_take_element_screenshot', - arguments: { selector: '.zero-sized' }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('selector: .edit-button'); - expect(text).toContain('format: png'); - expect(text).toContain('data: ZWxlbWVudC1zaG90'); - - expect(getResponseText(responses.find((message) => message.id === 3))).toContain( - 'Browser MCP failed: element not found for selector: .missing-button' - ); - expect(getResponseText(responses.find((message) => message.id === 4))).toContain( - 'Browser MCP failed: element has empty bounds for selector: .zero-sized' - ); - - const emptyPayloadResponses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Element Screenshot Empty Payload Page', - currentUrl: 'https://example.com/', - query: { - '.edit-button': { - exists: true, - connected: true, - rect: { - x: 220, - y: 80, - width: 64, - height: 28, - top: 80, - right: 284, - bottom: 108, - left: 220, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - screenshot: { data: '' }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_element_screenshot', - arguments: { selector: '.edit-button' }, - }, - }, - ] - ); - - expect(getResponseText(emptyPayloadResponses.find((message) => message.id === 2))).toContain( - 'Browser MCP failed: screenshot capture failed' - ); - - const iframeScreenshotResponses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Iframe Screenshot Page', - currentUrl: 'https://example.com/', - frames: [ - { - selector: '#details-frame', - query: { - '#details-frame': { - exists: true, - connected: true, - rect: { - x: 40, - y: 100, - width: 300, - height: 200, - top: 100, - right: 340, - bottom: 300, - left: 40, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - '.save-button': { - exists: true, - connected: true, - rect: { - x: 10, - y: 20, - width: 60, - height: 24, - top: 20, - right: 70, - bottom: 44, - left: 10, - }, - display: 'block', - visibility: 'visible', - opacity: '1', - }, - }, - }, - ], - screenshot: { - data: 'aWZyYW1lLXNob3Q=', - requireScrolledMeasurement: true, - expectedClip: { - x: 50, - y: 120, - width: 60, - height: 24, - scale: 1, - }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { - name: 'browser_take_element_screenshot', - arguments: { selector: '.save-button', frameSelector: '#details-frame' }, - }, - }, - ] - ); - - expect( - getResponseText(iframeScreenshotResponses.find((message) => message.id === 5)) - ).toContain('data: aWZyYW1lLXNob3Q='); - }); - - it('captures screenshots and reports empty payload failures', async () => { - const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' }; - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - screenshot: screenshotPlan, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_screenshot', - arguments: { fullPage: true }, - }, - }, - ] - ); - - const text = getResponseText(responses.find((message) => message.id === 2)); - expect(text).toContain('pageIndex: 0'); - expect(text).toContain('format: png'); - expect(text).toContain('fullPage: true'); - expect(text).toContain('data: c2NyZWVuc2hvdA=='); - expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true); - - const errorResponses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - screenshot: { data: '' }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_take_screenshot', - arguments: {}, - }, - }, - ] - ); - - const errorResponse = errorResponses.find((message) => message.id === 2); - expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(errorResponse)).toContain( - 'Browser MCP failed: screenshot capture failed' - ); - }); - - it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { - const responses = await runMcpRequests( - [ - { - id: 'page-1', - title: 'Example Page', - currentUrl: 'https://example.com/', - type: { - 'input[name="email"]': { - kind: 'input', - inputType: 'email', - value: 'old@example.com', - expectedValueWhenAppend: 'old@example.comhi@example.com', - requireFocus: true, - }, - '#notes': { - kind: 'textarea', - value: 'old note', - expectedValueWhenClearFirst: '', - requireFocus: true, - }, - '#editor': { - kind: 'contenteditable', - value: 'old content', - expectedValueWhenAppend: 'old contentrich text', - requireFocus: true, - }, - '#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' }, - '#plain': { kind: 'noneditable', value: 'plain' }, - }, - }, - ], - [ - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: 'input[name="email"]', text: 'hi@example.com' }, - }, - }, - { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#notes', text: '', clearFirst: true }, - }, - }, - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#editor', text: 'rich text' }, - }, - }, - { - jsonrpc: '2.0', - id: 5, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#color', text: 'ignored' }, - }, - }, - { - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { - name: 'browser_type', - arguments: { selector: '#plain', text: 'ignored' }, - }, - }, - ] - ); - - const typed = getResponseText(responses.find((message) => message.id === 2)); - expect(typed).toContain('status: typed'); - expect(typed).toContain('selector: input[name="email"]'); - expect(typed).toContain('typedLength: 29'); - - const clearFirst = getResponseText(responses.find((message) => message.id === 3)); - expect(clearFirst).toContain('status: typed'); - expect(clearFirst).toContain('selector: #notes'); - expect(clearFirst).toContain('typedLength: 0'); - - const contenteditable = getResponseText(responses.find((message) => message.id === 4)); - expect(contenteditable).toContain('status: typed'); - expect(contenteditable).toContain('selector: #editor'); - expect(contenteditable).toContain('typedLength: 20'); - - const unsupported = responses.find((message) => message.id === 5); - expect((unsupported?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(unsupported)).toContain( - 'element is not text-editable for selector: #color' - ); - - const nonEditable = responses.find((message) => message.id === 6); - expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true); - expect(getResponseText(nonEditable)).toContain( - 'element is not text-editable for selector: #plain' - ); - }); -}); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 70b51b02..e89e8855 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -348,6 +348,41 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).not.toContain('stale-settings-env'); }); + it('scrubs CCS_BROWSER_* values embedded in settings-profile env when browser is off', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_MODEL: 'glm-5', + CCS_BROWSER_USER_DATA_DIR: '/tmp/settings-browser-runtime', + CCS_BROWSER_PROFILE_DIR: '/tmp/settings-browser-legacy', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env', + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).not.toContain('/tmp/settings-browser-runtime'); + expect(launchedEnv).not.toContain('/tmp/settings-browser-legacy'); + expect(launchedEnv).not.toContain('settings-env'); + }); + it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => { if (process.platform === 'win32') return; From 508123166774ecbcb76101bbd4aa6209f693b607 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 23:20:11 -0400 Subject: [PATCH 47/47] test(ci): keep split browser MCP suites in slow bucket --- scripts/run-test-bucket.js | 7 ++++++- tests/unit/scripts/run-test-bucket.test.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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/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); });