From 4c46d6c5aede763d51b7bfb4cc025f601d0a8a48 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 25 Apr 2026 14:46:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(browser):=20=E4=BF=AE=E5=A4=8D=20DevTools?= =?UTF-8?q?=20=E9=A1=B5=E9=9D=A2=E5=88=9B=E5=BB=BA=E5=92=8C=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兼容 Chrome DevTools /json/new 的 PUT 方法,过滤不可用的 top-chrome 页面目标,并在截图前对 0x0 viewport 给出明确错误。 Co-Authored-By: Claude Opus 4.7 --- lib/mcp/ccs-browser-server.cjs | 430 +++++++++++++----- .../browser-mcp-advanced-interactions.test.ts | 130 +++++- ...browser-mcp-session-and-intercepts.test.ts | 209 +++++++-- tests/unit/hooks/browser-mcp-test-harness.ts | 183 +++++--- 4 files changed, 717 insertions(+), 235 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 1cc92e38..a4234c78 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -298,7 +298,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, @@ -314,7 +315,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, @@ -330,7 +332,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, @@ -346,7 +349,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, url: { type: 'string', @@ -367,7 +371,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -376,11 +381,13 @@ function getTools() { nth: { type: 'integer', minimum: 0, - description: 'Optional zero-based match index for selectors returning multiple elements.', + 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.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -388,11 +395,13 @@ function getTools() { }, offsetX: { type: 'number', - description: "Optional horizontal offset in CSS pixels from the target element's left edge.", + 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.", + description: + "Optional vertical offset in CSS pixels from the target element's top edge.", }, button: { type: 'string', @@ -419,7 +428,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -448,7 +458,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, key: { type: 'string', @@ -482,7 +493,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -490,7 +502,8 @@ function getTools() { }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -516,7 +529,8 @@ function getTools() { }, { name: TOOL_SELECT_PAGE, - description: 'Select the current page target by page index or page id for subsequent browser tool calls.', + description: + 'Select the current page target by page index or page id for subsequent browser tool calls.', inputSchema: { type: 'object', properties: { @@ -539,7 +553,8 @@ function getTools() { }, { name: TOOL_CLOSE_PAGE, - description: 'Close a browser page target by page index or page id. Defaults to the selected page.', + description: + 'Close a browser page target by page index or page id. Defaults to the selected page.', inputSchema: { type: 'object', properties: { @@ -769,7 +784,8 @@ function getTools() { }, { name: TOOL_START_RECORDING, - description: 'Start recording real browser interactions on the selected page and store them as structured steps.', + description: + 'Start recording real browser interactions on the selected page and store them as structured steps.', inputSchema: { type: 'object', properties: { @@ -781,7 +797,8 @@ function getTools() { }, { name: TOOL_STOP_RECORDING, - description: 'Stop the active browser recording session and keep the recorded result in session-local state.', + description: + 'Stop the active browser recording session and keep the recorded result in session-local state.', inputSchema: { type: 'object', properties: {}, @@ -808,7 +825,8 @@ function getTools() { }, { name: TOOL_START_REPLAY, - description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.', + description: + 'Start replaying a sequence of structured Browser MCP steps on the selected page.', inputSchema: { type: 'object', properties: { @@ -840,7 +858,8 @@ function getTools() { }, { name: TOOL_START_ORCHESTRATION, - description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', + description: + 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', inputSchema: { type: 'object', properties: { @@ -854,7 +873,8 @@ function getTools() { }, { name: TOOL_GET_ORCHESTRATION, - description: 'Read the current orchestration status and result summary from session-local state.', + description: + 'Read the current orchestration status and result summary from session-local state.', inputSchema: { type: 'object', properties: {}, @@ -863,7 +883,8 @@ function getTools() { }, { name: TOOL_CANCEL_ORCHESTRATION, - description: 'Cancel the active orchestration session and keep the summary in session-local state.', + description: + 'Cancel the active orchestration session and keep the summary in session-local state.', inputSchema: { type: 'object', properties: {}, @@ -872,7 +893,8 @@ function getTools() { }, { name: TOOL_EXPORT_ARTIFACT, - description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.', + description: + 'Export the current recording, replay, or orchestration artifact to a local JSON file.', inputSchema: { type: 'object', properties: { @@ -885,7 +907,8 @@ function getTools() { }, { name: TOOL_IMPORT_ARTIFACT, - description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', + description: + 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', inputSchema: { type: 'object', properties: { @@ -926,7 +949,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, fullPage: { type: 'boolean', @@ -946,7 +970,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -955,11 +980,13 @@ function getTools() { nth: { type: 'integer', minimum: 0, - description: 'Optional zero-based match index for selectors returning multiple elements.', + 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.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -994,7 +1021,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, expression: { type: 'string', @@ -1020,7 +1048,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -1028,7 +1057,8 @@ function getTools() { }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -1049,7 +1079,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -1058,11 +1089,13 @@ function getTools() { nth: { type: 'integer', minimum: 0, - description: 'Optional zero-based match index for selectors returning multiple elements.', + 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.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -1088,7 +1121,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', @@ -1096,7 +1130,8 @@ function getTools() { }, frameSelector: { type: 'string', - description: 'Optional CSS selector for an iframe whose document should be used as the query root.', + description: + 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', @@ -1116,7 +1151,8 @@ function getTools() { pageIndex: { type: 'integer', minimum: 0, - description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', + description: + 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, timeoutMs: { type: 'integer', @@ -1135,14 +1171,25 @@ function getTools() { ]; } -async function fetchJson(url) { - const response = await fetch(url); +async function fetchJson(url, options = undefined) { + const response = await fetch(url, options); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return await response.json(); } +function isUsablePageTarget(target) { + if (!target || typeof target !== 'object' || target.type !== 'page') { + return false; + } + const url = typeof target.url === 'string' ? target.url : ''; + if (url.startsWith('chrome://omnibox-popup') || url.startsWith('chrome://top-chrome')) { + return false; + } + return true; +} + function getHttpUrl() { const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; if (!value) { @@ -1157,16 +1204,14 @@ async function listPageTargets() { throw new Error('Browser MCP received an invalid /json/list response.'); } - return targets - .filter((target) => target && typeof target === 'object' && target.type === 'page') - .map((target) => ({ - id: typeof target.id === 'string' ? target.id : '', - title: typeof target.title === 'string' ? target.title : '', - url: typeof target.url === 'string' ? target.url : '', - type: typeof target.type === 'string' ? target.type : 'page', - webSocketDebuggerUrl: - typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', - })); + return targets.filter(isUsablePageTarget).map((target) => ({ + id: typeof target.id === 'string' ? target.id : '', + title: typeof target.title === 'string' ? target.title : '', + url: typeof target.url === 'string' ? target.url : '', + type: typeof target.type === 'string' ? target.type : 'page', + webSocketDebuggerUrl: + typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', + })); } function parsePageIndex(toolArgs) { @@ -1314,9 +1359,13 @@ function parseOptionalHeaderMatchers(value) { } const name = requireNonEmptyString(entry.name, 'headerMatchers.name'); const valueIncludes = - entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); + entry.valueIncludes === undefined + ? '' + : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); const valueRegex = - entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); + entry.valueRegex === undefined + ? '' + : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); if (!valueIncludes && !valueRegex) { throw new Error('headerMatchers entry must include valueIncludes or valueRegex'); } @@ -1342,7 +1391,14 @@ function validateInterceptMatcherSet({ if (urlPattern && urlRegex) { throw new Error('urlPattern and urlRegex cannot be used together'); } - if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) { + if ( + !urlIncludes && + !method && + !resourceType && + !urlPattern && + !urlRegex && + headerMatchers.length === 0 + ) { throw new Error('at least one matching condition is required'); } } @@ -1505,7 +1561,9 @@ function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, const selectedIndex = findPageIndexById(pages, defaultSelectedId); if (selectedIndex === -1) { if (!allowImplicitFallback && defaultSelectedId) { - throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); + throw new Error( + 'Selected page is no longer available; specify pageIndex or pageId explicitly.' + ); } const fallbackPage = pages[0]; if (!fallbackPage) { @@ -1526,7 +1584,9 @@ async function getSelectedPage(toolArgs) { 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).`); + 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.`); @@ -1655,7 +1715,11 @@ async function getBrowserTarget() { const browserTarget = Array.isArray(targets) ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') : null; - if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + if ( + !browserTarget || + typeof browserTarget.webSocketDebuggerUrl !== 'string' || + !browserTarget.webSocketDebuggerUrl + ) { throw new Error('browser-level download events are unavailable'); } return browserTarget; @@ -1795,11 +1859,15 @@ async function ensureBrowserDownloadSession() { 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() : '', + finishedAt: + status === 'completed' || status === 'canceled' ? new Date().toISOString() : '', }); } })(); - activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + activityChain = activityChain + .catch(() => {}) + .then(() => activity) + .catch(() => {}); void activity.catch(() => {}); }); @@ -1836,7 +1904,7 @@ async function evaluateInPage(page, kind) { throw new Error(result.description || 'DevTools evaluation returned an error.'); } - return typeof result.value === 'string' ? result.value : result.value ?? ''; + return typeof result.value === 'string' ? result.value : (result.value ?? ''); } async function evaluateExpression(page, expression) { @@ -1854,7 +1922,7 @@ async function evaluateExpression(page, expression) { throw new Error(result.description || 'DevTools evaluation returned an error.'); } - return typeof result.value === 'string' ? result.value : result.value ?? ''; + return typeof result.value === 'string' ? result.value : (result.value ?? ''); } async function withPageCommandSession(page, callback) { @@ -2084,7 +2152,10 @@ function requireOptionalStringArray(value, label, allowedValues) { if (value === undefined) { return []; } - if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) { + 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()); @@ -2282,7 +2353,10 @@ function buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow } async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow) { - const raw = await evaluateExpression(page, buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow)); + const raw = await evaluateExpression( + page, + buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow) + ); return JSON.parse(raw); } @@ -2573,9 +2647,7 @@ function readArtifactFile(filePath) { 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` - ); + throw new Error(`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`); } const raw = fs.readFileSync(filePath, 'utf8'); let parsed; @@ -2590,7 +2662,11 @@ function readArtifactFile(filePath) { if (parsed.version !== 1) { throw new Error('unsupported artifact version'); } - if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) { + if ( + typeof parsed.kind !== 'string' || + typeof parsed.name !== 'string' || + !('payload' in parsed) + ) { throw new Error('invalid artifact payload'); } return parsed; @@ -2667,10 +2743,7 @@ function formatOrchestrationSummary(session) { } async function waitForSessionToSettle(task) { - await Promise.race([ - task.then(() => undefined), - sleep(SESSION_START_SETTLE_WINDOW_MS), - ]); + await Promise.race([task.then(() => undefined), sleep(SESSION_START_SETTLE_WINDOW_MS)]); } function createReplaySession(page, pageIndex, steps) { @@ -2961,7 +3034,12 @@ function validateSequenceStep(step) { } function requireCrossPageRunBlock(block) { - if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') { + 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)) { @@ -3117,7 +3195,8 @@ function matchesObservedEvent(event, observed) { if (event.kind === 'download') { return ( (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && - (!event.suggestedFilenameIncludes || String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) + (!event.suggestedFilenameIncludes || + String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) ); } return false; @@ -3128,7 +3207,10 @@ function sleep(ms) { } async function getNavigationState(page) { - const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`); + const raw = await evaluateExpression( + page, + `JSON.stringify({ href: location.href, readyState: document.readyState })` + ); const parsed = JSON.parse(raw); return { href: typeof parsed.href === 'string' ? parsed.href : '', @@ -3207,10 +3289,16 @@ 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 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))}); @@ -3445,7 +3533,8 @@ async function handleType(toolArgs) { const raw = await evaluateExpression(page, expression); const parsed = JSON.parse(raw); - const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; + const typedLength = + typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; } @@ -3458,7 +3547,8 @@ async function handlePressKey(toolArgs) { 'Meta', 'Shift', ]); - const repeat = toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); + const repeat = + toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); const modifierMask = (modifiers.includes('Alt') ? 1 : 0) | (modifiers.includes('Control') ? 2 : 0) | @@ -3626,8 +3716,19 @@ async function handleScroll(toolArgs) { return lines.join('\n'); } +async function ensureDrawableViewport(page) { + const metrics = await sendCdpCommand(page, 'Page.getLayoutMetrics'); + const viewport = metrics.cssVisualViewport || metrics.visualViewport || {}; + const width = Number(viewport.clientWidth || 0); + const height = Number(viewport.clientHeight || 0); + if (width <= 0 || height <= 0) { + throw new Error('page has no drawable viewport for screenshot'); + } +} + async function handleScreenshot(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); + await ensureDrawableViewport(page); const fullPage = toolArgs.fullPage === true; const response = await sendCdpCommand(page, 'Page.captureScreenshot', { format: 'png', @@ -3642,11 +3743,23 @@ async function handleScreenshot(toolArgs) { return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } -async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) { +async function getElementDiagnostics( + page, + selector, + nth, + frameSelector = '', + pierceShadow = false +) { return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); } -async function getScrolledElementStateAt(page, selector, nth, frameSelector = '', pierceShadow = false) { +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)}; @@ -3860,7 +3973,9 @@ function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) { } if (diagnostics.targetMissing) { if (hasTargetSpecificQueryField(fields)) { - throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`); + throw new Error( + `element index ${diagnostics.targetIndex} is out of range for selector: ${selector}` + ); } for (const field of fields) { if (field === 'exists') { @@ -3897,7 +4012,11 @@ async function handleWaitFor(toolArgs) { 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 timeoutMs = requirePositiveIntegerOrDefault( + toolArgs.timeoutMs, + 'timeoutMs', + DEFAULT_WAIT_TIMEOUT_MS + ); const pollIntervalMs = requirePositiveIntegerOrDefault( toolArgs.pollIntervalMs, 'pollIntervalMs', @@ -3993,7 +4112,14 @@ function interpolatePoints(sourcePoint, targetPoint, steps) { return points; } -async function resolveElementCenterPoint(page, selector, nth, frameSelector, pierceShadow, missingLabel) { +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); @@ -4041,7 +4167,10 @@ async function handlePointerAction(toolArgs) { } if (action.type === 'pause') { - const durationMs = action.durationMs === undefined ? 0 : requireNonNegativeInteger(action.durationMs, 'durationMs'); + const durationMs = + action.durationMs === undefined + ? 0 + : requireNonNegativeInteger(action.durationMs, 'durationMs'); if (durationMs > 0) { await sleep(durationMs); } @@ -4070,7 +4199,14 @@ async function handlePointerAction(toolArgs) { pointerX = point.x; pointerY = point.y; - await dispatchMousePointerEvent(page, 'mouseMoved', pointerX, pointerY, pressedButton || 'left', Boolean(pressedButton)); + await dispatchMousePointerEvent( + page, + 'mouseMoved', + pointerX, + pointerY, + pressedButton || 'left', + Boolean(pressedButton) + ); continue; } @@ -4082,21 +4218,36 @@ async function handlePointerAction(toolArgs) { 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); + pressedButton = + requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left'; + await dispatchMousePointerEvent( + page, + 'mousePressed', + pointerX, + pointerY, + pressedButton, + true + ); continue; } if (!pressedButton) { throw new Error('pointer state error'); } - const releaseButton = - requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton; - if (releaseButton !== pressedButton) { - throw new Error('pointer state error'); - } - await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false); - pressedButton = null; + 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) { @@ -4161,14 +4312,24 @@ async function handleDragElement(toolArgs) { y: requireFiniteNumber(toolArgs.targetY, 'targetY'), }; - const steps = toolArgs.steps === undefined ? DEFAULT_DRAG_STEPS : requirePositiveInteger(toolArgs.steps, 'steps'); + 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); + await dispatchMousePointerEvent( + page, + 'mouseReleased', + targetPoint.x, + targetPoint.y, + 'left', + false + ); return [ `pageIndex: ${pageIndex}`, @@ -4197,7 +4358,14 @@ async function handleHover(toolArgs) { throw new Error(`element is hidden or not interactable for selector: ${selector}`); } - await dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false); + await dispatchMousePointerEvent( + page, + 'mouseMoved', + state.centerPoint.x, + state.centerPoint.y, + 'left', + false + ); return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`; } @@ -4231,6 +4399,7 @@ async function handleElementScreenshot(toolArgs) { if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) { throw new Error(`element has empty bounds for selector: ${selector}`); } + await ensureDrawableViewport(page); const response = await sendCdpCommand(page, 'Page.captureScreenshot', { format: 'png', @@ -4363,7 +4532,11 @@ async function waitForBrowserDownloadEvent(page, timeoutMs, event) { const browserTarget = Array.isArray(targets) ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') : null; - if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { + if ( + !browserTarget || + typeof browserTarget.webSocketDebuggerUrl !== 'string' || + !browserTarget.webSocketDebuggerUrl + ) { throw new Error('browser-level download events are unavailable'); } @@ -4449,7 +4622,11 @@ async function waitForMatchingEvent({ page, timeoutMs, event }) { async function handleWaitForEvent(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); - const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); + 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)}`; @@ -4628,7 +4805,9 @@ async function ensureInterceptSession(page) { return; } const paused = message.params || {}; - const matchedRule = getRulesForMatching(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( @@ -4671,7 +4850,10 @@ async function ensureInterceptSession(page) { statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, }); })(); - activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + activityChain = activityChain + .catch(() => {}) + .then(() => activity) + .catch(() => {}); void activity.catch(() => {}); }); @@ -4716,7 +4898,7 @@ async function handleOpenPage(toolArgs) { const query = toolArgs?.url ? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}` : ''; - const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`); + const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`, { method: 'PUT' }); const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : ''; if (!pageId) { throw new Error('Browser MCP failed to create a new page target.'); @@ -4763,7 +4945,10 @@ async function handleClosePage(toolArgs) { if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) { selectedPageId = previousSelectedPageId; } else { - selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0); + selectedPageId = resolveFallbackSelectedPageId( + remainingPages, + pageIndex > 0 ? pageIndex - 1 : 0 + ); } const selectedIndex = findPageIndexById(remainingPages, selectedPageId); return [ @@ -4805,7 +4990,9 @@ async function handleAddInterceptRule(toolArgs) { }); const statusCode = parseOptionalStatusCode(toolArgs.statusCode); const contentType = - toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); + toolArgs.contentType === undefined + ? '' + : requireNonEmptyString(toolArgs.contentType, 'contentType'); const body = parseOptionalBody(toolArgs.body); const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType); const rule = { @@ -4900,7 +5087,8 @@ async function handleListRequests(toolArgs) { 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; + const eventsEnabled = + toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true; if (behavior === 'deny' && downloadPath) { throw new Error('downloadPath is only allowed when behavior=accept'); @@ -5172,7 +5360,9 @@ async function finalizeRecordingCapture(session) { const warnings = Array.isArray(value.warnings) ? value.warnings : []; session.rawEvents = rawEvents; - session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean); + session.steps = rawEvents + .map((event) => normalizeRecordedEvent(session.pageId, event)) + .filter(Boolean); session.warnings = warnings.map((warning) => String(warning.message || warning)); } @@ -5225,9 +5415,7 @@ async function handleStopRecording() { await finalizeRecordingCapture(session); } catch (error) { finalizeError = error instanceof Error ? error : new Error(String(error)); - session.warnings.push( - `recording capture finalization failed: ${finalizeError.message}` - ); + session.warnings.push(`recording capture finalization failed: ${finalizeError.message}`); } session.status = 'stopped'; session.stoppedAt = new Date().toISOString(); @@ -5325,7 +5513,9 @@ function buildReplayToolArgs(step, replayPage) { nth: step.nth, frameSelector: step.frameSelector, pierceShadow: step.pierceShadow, - ...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}), + ...(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 } : {}), @@ -5430,9 +5620,7 @@ async function handleStartReplay(toolArgs) { latestReplaySession = session; activeReplaySession = session; - const task = Promise.resolve().then(() => - runReplaySession(session, { manageActiveState: true }) - ); + const task = Promise.resolve().then(() => runReplaySession(session, { manageActiveState: true })); activeReplayTask = task; task.catch(() => { // Session state is recorded in runReplaySession. @@ -5718,9 +5906,7 @@ async function handleExportArtifact(toolArgs) { const artifact = buildArtifact(kind, name, payload); const serialized = JSON.stringify(artifact, null, 2); if (Buffer.byteLength(serialized, 'utf8') > MAX_ARTIFACT_FILE_BYTES) { - throw new Error( - `artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes` - ); + 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`; @@ -5736,12 +5922,14 @@ async function handleListArtifacts() { return 'artifacts: []'; } return entries - .map((entry) => [ - `name: ${entry.name}`, - `kind: ${entry.kind}`, - `createdAt: ${entry.createdAt}`, - `path: ${getArtifactPath(entry.name)}`, - ].join('\n')) + .map((entry) => + [ + `name: ${entry.name}`, + `kind: ${entry.kind}`, + `createdAt: ${entry.createdAt}`, + `path: ${getArtifactPath(entry.name)}`, + ].join('\n') + ) .join('\n---\n'); } diff --git a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts index bb6d42a4..c31c4b60 100644 --- a/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts +++ b/tests/unit/hooks/browser-mcp-advanced-interactions.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'bun:test'; -import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; +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', () => { @@ -88,7 +98,9 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]); - expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped'); + expect(getResponseText(responses.find((message) => message.id === 901))).toContain( + 'status: files-dropped' + ); expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([ 'dragenter', 'dragover', @@ -99,7 +111,8 @@ describe('ccs-browser MCP server - advanced interactions', () => { { name: 'receipt.png', size: 7, type: 'image/png' }, ]); expect( - getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles + 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 @@ -145,7 +158,12 @@ describe('ccs-browser MCP server - advanced interactions', () => { method: 'tools/call', params: { name: 'browser_drag_files', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] }, + arguments: { + pageIndex: 0, + pageId: 'page-1', + selector: '#reject-dropzone', + files: [okPath], + }, }, }, { @@ -169,7 +187,9 @@ describe('ccs-browser MCP server - advanced interactions', () => { ] ); - expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist'); + 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' ); @@ -297,13 +317,27 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]); - 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(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' }, @@ -360,7 +394,12 @@ describe('ccs-browser MCP server - advanced interactions', () => { method: 'tools/call', params: { name: 'browser_drag_element', - arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 }, + arguments: { + selector: '#card-a', + targetSelector: '#lane-b', + targetX: 400, + targetY: 200, + }, }, }, { @@ -387,7 +426,13 @@ describe('ccs-browser MCP server - advanced interactions', () => { method: 'tools/call', params: { name: 'browser_drag_element', - arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 }, + arguments: { + pageIndex: 0, + pageId: 'page-1', + selector: '#card-a', + targetX: 400, + targetY: 200, + }, }, }, ] @@ -485,19 +530,21 @@ describe('ccs-browser MCP server - advanced interactions', () => { name: 'browser_pointer_action', arguments: { pageId: 'page-2', - actions: [ - { type: 'move', selector: '#handle' }, - { type: 'down' }, - { type: 'up' }, - ], + 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(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' }, @@ -592,8 +639,12 @@ describe('ccs-browser MCP server - advanced interactions', () => { }, ]); - 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 === 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' ); @@ -1177,6 +1228,37 @@ describe('ccs-browser MCP server - advanced interactions', () => { ); }); + it('reports a clear error before screenshot capture when the selected page has a zero viewport', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Popup Target', + currentUrl: 'https://example.com/', + viewport: { width: 0, height: 0 }, + screenshot: { data: 'c2NyZWVuc2hvdA==' }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'browser_take_screenshot', + arguments: {}, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 3); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: page has no drawable viewport for screenshot' + ); + }); + it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => { const responses = await runMcpRequests( [ diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 554a25c7..50954827 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -126,15 +126,21 @@ describe('ccs-browser MCP server - session and interception', () => { 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' }); + 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?.downloadId).toMatchObject({ + type: 'string', + }); expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' }); const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input'); @@ -157,7 +163,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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?.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' }); @@ -166,7 +174,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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?.pageIndex).toMatchObject({ + type: 'integer', + }); expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording'); @@ -189,15 +199,25 @@ describe('ccs-browser MCP server - session and interception', () => { 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 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'); + 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'); @@ -289,6 +309,55 @@ describe('ccs-browser MCP server - session and interception', () => { expect(text).toContain('Docs text'); }); + it('opens a page when Chrome DevTools requires PUT for /json/new', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 820, + method: 'tools/call', + params: { + name: 'browser_open_page', + arguments: { url: 'https://example.com/new' }, + }, + }, + ], + { requirePutForNewPage: true } + ); + + const openText = getResponseText(responses.find((message) => message.id === 820)); + expect(openText).toContain('status: opened'); + expect(openText).toContain('url: https://example.com/new'); + }); + + it('omits non-browser popup targets from session info', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Omnibox Popup', + currentUrl: 'chrome://omnibox-popup.top-chrome/', + }, + { id: 'page-2', title: 'Home', currentUrl: 'https://example.com/' }, + ], + [ + { + jsonrpc: '2.0', + id: 8201, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 8201)); + expect(text).toContain('0. Home'); + expect(text).toContain('selected: true'); + expect(text).not.toContain('Omnibox Popup'); + expect(text).not.toContain('chrome://omnibox-popup.top-chrome/'); + }); + it('opens a page and makes it selected', async () => { const responses = await runMcpRequests( [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], @@ -717,7 +786,9 @@ describe('ccs-browser MCP server - session and interception', () => { title: 'Docs', currentUrl: 'https://example.com/docs', intercept: { - pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], + pausedRequests: [ + { requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }, + ], }, }, ], @@ -801,7 +872,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -825,7 +898,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -850,7 +925,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -899,7 +976,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: headerMatchers must be an array' + ); }); it('rejects intercept rules when a header matcher is missing name', async () => { @@ -923,7 +1002,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: headerMatchers.name is required' + ); }); it('rejects intercept rules when a header matcher has no value matcher', async () => { @@ -947,7 +1028,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -971,7 +1054,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -1556,7 +1641,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -1582,7 +1669,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: responseHeaders must be an array' + ); }); it('rejects fulfill rules when a responseHeaders entry is missing name', async () => { @@ -1608,7 +1697,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: responseHeaders.name is required' + ); }); it('rejects fulfill rules when body is not a string', async () => { @@ -1657,7 +1748,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + expect(getResponseText(response)).toContain( + 'Browser MCP failed: urlRegex must be a valid regular expression' + ); }); it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => { @@ -1681,7 +1774,9 @@ describe('ccs-browser MCP server - session and interception', () => { 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'); + 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 () => { @@ -1694,13 +1789,22 @@ describe('ccs-browser MCP server - session and interception', () => { currentUrl: 'https://example.com/mocked', intercept: { pausedRequests: [ - { requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' }, + { + 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: 972, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, { jsonrpc: '2.0', id: 973, @@ -1715,19 +1819,45 @@ describe('ccs-browser MCP server - session and interception', () => { }, }, }, - { 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: {} } }, + { + 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'); + 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 () => { @@ -1739,8 +1869,18 @@ describe('ccs-browser MCP server - session and interception', () => { 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' }, + { + 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', + }, ], }, }, @@ -1813,5 +1953,4 @@ describe('ccs-browser MCP server - session and interception', () => { 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 index fd78c81f..4027ac5a 100644 --- a/tests/unit/hooks/browser-mcp-test-harness.ts +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -268,6 +268,7 @@ type MockPageState = { id: string; title: string; currentUrl: string; + targetType?: string; fileInputs?: Record; browser?: MockBrowserState; frameTree?: MockFrameTree; @@ -288,6 +289,10 @@ type MockPageState = { recording?: MockRecordingPlan; events?: MockPageEventPlan; intercept?: MockInterceptState; + viewport?: { + width: number; + height: number; + }; screenshot?: { expectedClip?: { x: number; @@ -342,6 +347,7 @@ type RunMcpRequestsOptions = { serverPath?: string; childEnv?: NodeJS.ProcessEnv; responseTimeoutMs?: number; + requirePutForNewPage?: boolean; }; function encodeMessage(message: unknown): string { @@ -688,7 +694,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { JSON.stringify([ ...pageEntries.map(([wsPath, page]) => ({ id: page.id, - type: 'page', + type: page.targetType || 'page', title: page.title, url: page.currentUrl, webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`, @@ -706,6 +712,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (req.url?.startsWith('/json/new')) { + if (options.requirePutForNewPage && req.method !== 'PUT') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'method not allowed' })); + return; + } + 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}`; @@ -764,7 +776,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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 = {}) : {}); + const browserState = + pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {}); socket.on('message', (raw) => { const message = JSON.parse(raw.toString()) as { @@ -782,9 +795,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { browserState.setDownloadBehaviorCalls.push({ behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '', downloadPath: - typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined, + typeof message.params?.downloadPath === 'string' + ? message.params.downloadPath + : undefined, eventsEnabled: - typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined, + typeof message.params?.eventsEnabled === 'boolean' + ? message.params.eventsEnabled + : undefined, }); reply({}); return; @@ -801,35 +818,41 @@ function createMockBrowser(pagesInput: MockPageState[]) { 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(() => { + setTimeout( + () => { socket.send( JSON.stringify({ - method: 'Browser.downloadProgress', + method: 'Browser.downloadWillBegin', params: { + frameId: download.frameId || `frame-${page.id}`, guid, - totalBytes: progress.totalBytes, - receivedBytes: progress.receivedBytes, - state: progress.state, - filePath: progress.filePath, + url: download.url, + suggestedFilename: download.suggestedFilename, }, }) ); - }, 20 + index * 40 + progressIndex * 20); + }, + 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 + ); } } } @@ -881,7 +904,31 @@ function createMockBrowser(pagesInput: MockPageState[]) { if (message.method === 'Page.getFrameTree') { const nextFrameTree = page.frameTreeSequence?.shift(); - reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } }); + reply({ + frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } }, + }); + return; + } + + if (message.method === 'Page.getLayoutMetrics') { + const width = page.viewport?.width ?? 1280; + const height = page.viewport?.height ?? 720; + reply({ + visualViewport: { + pageX: 0, + pageY: 0, + clientWidth: width, + clientHeight: height, + scale: 1, + }, + cssVisualViewport: { + pageX: 0, + pageY: 0, + clientWidth: width, + clientHeight: height, + scale: 1, + }, + }); return; } @@ -916,7 +963,8 @@ 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; + 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 }; @@ -1017,28 +1065,33 @@ function createMockBrowser(pagesInput: MockPageState[]) { ? (message.params?.patterns as unknown[]) : []; if (page.intercept.enableError) { - socket.send(JSON.stringify({ id: message.id, error: { message: 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 || {}, + 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); + }) + ); + }, + pauseDispatchDelayMs + index * 10 + ); } return; } @@ -1056,7 +1109,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { page.intercept.failedRequests = page.intercept.failedRequests || []; page.intercept.failedRequests.push({ requestId: String(message.params?.requestId || ''), - errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', + errorReason: + typeof message.params?.errorReason === 'string' ? message.params.errorReason : '', }); reply({}); return; @@ -1068,7 +1122,9 @@ function createMockBrowser(pagesInput: MockPageState[]) { page.intercept.fulfilledRequests.push({ requestId: String(message.params?.requestId || ''), responseCode: - typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined, + typeof message.params?.responseCode === 'number' + ? message.params.responseCode + : undefined, responseHeaders: Array.isArray(message.params?.responseHeaders) ? (message.params.responseHeaders as Array<{ name: string; value: string }>) : [], @@ -1079,10 +1135,13 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (message.method === 'DOM.setFileInputFiles') { - const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : ''; + 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' } })); + socket.send( + JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } }) + ); return; } target.assignedFiles = Array.isArray(message.params?.files) @@ -1115,7 +1174,10 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) { + if ( + recordingPayload && + (recordingPayload.events?.length || recordingPayload.warnings?.length) + ) { plan.events = recordingPayload.events || []; plan.warnings = recordingPayload.warnings || []; } @@ -1124,7 +1186,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) { + if ( + expression.includes( + 'globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }' + ) + ) { const plan = getMockRecordingPlan(page); if (plan.finalizeError) { socket.send( @@ -1405,7 +1471,8 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if ( resolvedClickPlan.expectedOffset && - (offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y) + (offsetX !== resolvedClickPlan.expectedOffset.x || + offsetY !== resolvedClickPlan.expectedOffset.y) ) { replyError(`unexpected click offset for selector: ${selector}`); return; @@ -1721,11 +1788,18 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } - if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) { + 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 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}`); @@ -1838,7 +1912,6 @@ async function runMcpRequests( } } - export { bundledServerPath, runMcpRequests,