From 6333da32d0a9070356fd04b635fcf05e46868df5 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 01:08:40 -0600 Subject: [PATCH 001/127] refactor: reorganize tool imports and remove deprecated tests - Updated `tools.ts` to replace emulation and extension tools with debug bridge and evaluation tools. - Deleted obsolete `browser.test.ts`, `emulation.test.ts`, `extensions.test.ts`, and `pages.test.ts` files to clean up the test suite. --- package-lock.json | 28 +- package.json | 9 +- src/McpContext.ts | 28 +- src/McpResponse.ts | 3 +- src/bridge-client.ts | 197 ++++++++ src/browser.ts | 814 ++++++++++++++++++++++++--------- src/cli.ts | 265 +---------- src/main.ts | 175 +++---- src/tools/categories.ts | 10 +- src/tools/debug-bridge-exec.ts | 104 +++++ src/tools/debug-evaluate.ts | 83 ++++ src/tools/emulation.ts | 200 -------- src/tools/extensions.ts | 87 ---- src/tools/pages.ts | 373 --------------- src/tools/tools.ts | 10 +- tests/browser.test.ts | 99 ---- tests/tools/emulation.test.ts | 575 ----------------------- tests/tools/extensions.test.ts | 127 ----- tests/tools/pages.test.ts | 606 ------------------------ 19 files changed, 1133 insertions(+), 2660 deletions(-) create mode 100644 src/bridge-client.ts create mode 100644 src/tools/debug-bridge-exec.ts create mode 100644 src/tools/debug-evaluate.ts delete mode 100644 src/tools/emulation.ts delete mode 100644 src/tools/extensions.ts delete mode 100644 src/tools/pages.ts delete mode 100644 tests/browser.test.ts delete mode 100644 tests/tools/emulation.test.ts delete mode 100644 tests/tools/extensions.test.ts delete mode 100644 tests/tools/pages.test.ts diff --git a/package-lock.json b/package-lock.json index 0867cd48c..13bdad6ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "chrome-devtools-mcp", "version": "0.16.0", "license": "Apache-2.0", + "dependencies": { + "get-port": "^7.1.0", + "ws": "^8.19.0" + }, "bin": { "chrome-devtools-mcp": "build/src/index.js" }, @@ -23,6 +27,7 @@ "@types/filesystem": "^0.0.36", "@types/node": "^25.0.0", "@types/sinon": "^21.0.0", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", @@ -1404,6 +1409,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4121,6 +4136,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -7635,7 +7662,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 436b74256..60d1b5d01 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "chrome-devtools-mcp", + "name": "vscode-devtools-mcp", "version": "0.16.0", - "description": "MCP server for Chrome DevTools", + "description": "MCP server for VS Code DevTools", "type": "module", "bin": "./build/src/index.js", "main": "index.js", @@ -51,6 +51,7 @@ "@types/filesystem": "^0.0.36", "@types/node": "^25.0.0", "@types/sinon": "^21.0.0", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", @@ -73,5 +74,9 @@ }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "dependencies": { + "get-port": "^7.1.0", + "ws": "^8.19.0" } } diff --git a/src/McpContext.ts b/src/McpContext.ts index 6184b1a94..a415423a8 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -30,15 +30,11 @@ import type { PredefinedNetworkConditions, Viewport, } from './third_party/index.js'; -import {listPages} from './tools/pages.js'; import {takeSnapshot} from './tools/snapshot.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; import type {Context, DevToolsData} from './tools/ToolDefinition.js'; import type {TraceResult} from './trace-processing/parse.js'; -import { - ExtensionRegistry, - type InstalledExtension, -} from './utils/ExtensionRegistry.js'; +import type {InstalledExtension} from './utils/ExtensionRegistry.js'; import {WaitForHelper} from './WaitForHelper.js'; export interface TextSnapshotNode extends SerializedAXNode { @@ -118,7 +114,7 @@ export class McpContext implements Context { #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; #devtoolsUniverseManager: UniverseManager; - #extensionRegistry = new ExtensionRegistry(); + // Extension registry removed — VS Code extensions are managed natively #isRunningTrace = false; #networkConditionsMap = new WeakMap(); @@ -391,7 +387,7 @@ export class McpContext implements Context { } if (page.isClosed()) { throw new Error( - `The selected page has been closed. Call ${listPages.name} to see open pages.`, + 'The selected page has been closed. Call list_editor_tabs to see open pages.', ); } return page; @@ -783,22 +779,20 @@ export class McpContext implements Context { await this.#networkCollector.init(await this.browser.pages()); } - async installExtension(extensionPath: string): Promise { - const id = await this.browser.installExtension(extensionPath); - await this.#extensionRegistry.registerExtension(id, extensionPath); - return id; + // Extension management removed — VS Code extensions are managed natively via bridge + async installExtension(_extensionPath: string): Promise { + throw new Error('Extension management is not supported in VS Code DevTools MCP. Use VS Code native extension management.'); } - async uninstallExtension(id: string): Promise { - await this.browser.uninstallExtension(id); - this.#extensionRegistry.remove(id); + async uninstallExtension(_id: string): Promise { + throw new Error('Extension management is not supported in VS Code DevTools MCP. Use VS Code native extension management.'); } listExtensions(): InstalledExtension[] { - return this.#extensionRegistry.list(); + return []; } - getExtension(id: string): InstalledExtension | undefined { - return this.#extensionRegistry.getById(id); + getExtension(_id: string): InstalledExtension | undefined { + return undefined; } } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 0ec11b541..916478938 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -17,7 +17,6 @@ import type { ResourceType, TextContent, } from './third_party/index.js'; -import {handleDialog} from './tools/pages.js'; import type { DevToolsData, ImageContentData, @@ -493,7 +492,7 @@ export class McpResponse implements Response { : ''; response.push(`# Open dialog ${dialog.type()}: ${dialog.message()}${defaultValueIfNeeded}. -Call ${handleDialog.name} to handle it before continuing.`); +Call handle_dialog to handle it before continuing.`); structuredContent.dialog = { type: dialog.type(), message: dialog.message(), diff --git a/src/bridge-client.ts b/src/bridge-client.ts new file mode 100644 index 000000000..304845e0e --- /dev/null +++ b/src/bridge-client.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import path from 'node:path'; + +import {logger} from './logger.js'; + +export interface BridgeResponse { + id: string; + ok: boolean; + result?: unknown; + error?: string; +} + +export interface AttachDebuggerResult { + attached: boolean; + port: number; + name: string; +} + +const BRIDGE_TIMEOUT_MS = 10_000; +const ATTACH_TIMEOUT_MS = 15_000; + +/** + * Discover the extension-bridge socket path for a given workspace. + * Reads .vscode/vscode-api-expose.sockpath written by extension-bridge on activation. + */ +export function discoverBridgePath(workspaceFolder: string): string { + const markerPath = path.join( + workspaceFolder, + '.vscode', + 'vscode-api-expose.sockpath', + ); + if (!fs.existsSync(markerPath)) { + throw new Error( + `Cannot find extension-bridge sockpath at ${markerPath}.\n` + + 'Ensure VS Code is running with the extension-bridge extension installed and active.\n' + + 'Install: code --install-extension extension-bridge', + ); + } + const socketPath = fs.readFileSync(markerPath, 'utf8').trim(); + if (!socketPath) { + throw new Error( + `Extension-bridge sockpath marker at ${markerPath} is empty.\n` + + 'The extension-bridge may have failed to start. Check VS Code output panel.', + ); + } + logger('Discovered bridge path:', socketPath); + return socketPath; +} + +/** + * Send an 'exec' command to extension-bridge and wait for response. + * The code runs in a `new Function('vscode', 'payload', ...)` context. + * `require()` is NOT available — only `vscode` API and `payload`. + */ +export function bridgeExec( + bridgePath: string, + code: string, + payload?: unknown, +): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection(bridgePath); + const reqId = `exec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + let response = ''; + client.setEncoding('utf8'); + + client.on('connect', () => { + const request = + JSON.stringify({id: reqId, action: 'exec', code, payload}) + '\n'; + client.write(request); + }); + + client.on('data', (chunk: string) => { + response += chunk; + const nlIdx = response.indexOf('\n'); + if (nlIdx !== -1) { + try { + const result = JSON.parse(response.slice(0, nlIdx)) as BridgeResponse; + client.end(); + if (result.ok) { + resolve(result.result); + } else { + reject( + new Error( + `extension-bridge exec failed: ${result.error ?? 'Unknown error'}`, + ), + ); + } + } catch (e) { + client.end(); + reject( + new Error( + `Failed to parse bridge response: ${(e as Error).message}`, + ), + ); + } + } + }); + + client.on('error', (err: Error) => { + reject(new Error(`Bridge connection error: ${err.message}`)); + }); + + const timeout = setTimeout(() => { + client.destroy(); + reject(new Error(`Bridge exec request timed out (${BRIDGE_TIMEOUT_MS}ms)`)); + }, BRIDGE_TIMEOUT_MS); + + client.on('close', () => { + clearTimeout(timeout); + }); + }); +} + +/** + * Tell the Host bridge to programmatically attach the VS Code debugger. + * Lights up the full debug UI: orange status bar, floating toolbar, call stack. + * + * Uses the bridge's 'attach-debugger' action which calls + * `vscode.debug.startDebugging(undefined, { type, request: 'attach', port })`. + */ +export function bridgeAttachDebugger( + bridgePath: string, + port: number, + name = `Extension Host (port ${port})`, +): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection(bridgePath); + const reqId = `attach-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + let response = ''; + client.setEncoding('utf8'); + + client.on('connect', () => { + const request = + JSON.stringify({ + id: reqId, + action: 'attach-debugger', + port, + type: 'node', + name, + }) + '\n'; + client.write(request); + }); + + client.on('data', (chunk: string) => { + response += chunk; + const nlIdx = response.indexOf('\n'); + if (nlIdx !== -1) { + try { + const result = JSON.parse(response.slice(0, nlIdx)) as BridgeResponse; + client.end(); + if (result.ok) { + resolve(result.result as AttachDebuggerResult); + } else { + reject( + new Error( + `Attach debugger failed: ${result.error ?? 'Unknown error'}`, + ), + ); + } + } catch (e) { + client.end(); + reject( + new Error( + `Failed to parse attach response: ${(e as Error).message}`, + ), + ); + } + } + }); + + client.on('error', (err: Error) => { + reject(new Error(`Bridge connection error: ${err.message}`)); + }); + + const timeout = setTimeout(() => { + client.destroy(); + reject( + new Error( + `Attach debugger request timed out (${ATTACH_TIMEOUT_MS}ms)`, + ), + ); + }, ATTACH_TIMEOUT_MS); + + client.on('close', () => { + clearTimeout(timeout); + }); + }); +} diff --git a/src/browser.ts b/src/browser.ts index 64db15681..db6429105 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -4,244 +4,648 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * VS Code DevTools MCP — Browser/Connection Layer + * + * Replaces Chrome DevTools MCP's browser.ts with VS Code-specific logic: + * 1. Discovers the Host VS Code's extension-bridge via sockpath marker + * 2. Allocates dynamic ports (CDP + Extension Host inspector) via get-port + * 3. Spawns an Extension Development Host via child_process.spawn() + * 4. Connects via raw CDP WebSocket to the page-level target + * (Puppeteer browser-level connect FAILS on Electron) + * 5. Polls for workbench readiness (.monaco-workbench + document.readyState) + * 6. Provides lifecycle management (cleanup on exit/crash) + * + * SINGLETON: Only one child VS Code instance at a time. If the server exits + * for any reason (clean, SIGINT, SIGTERM, crash) the child is killed instantly. + */ + import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import {spawn, execSync, type ChildProcess} from 'node:child_process'; +import getPort from 'get-port'; +import WebSocket from 'ws'; + +import { + discoverBridgePath, + bridgeExec, + bridgeAttachDebugger, +} from './bridge-client.js'; import {logger} from './logger.js'; -import type { - Browser, - ChromeReleaseChannel, - LaunchOptions, - Target, -} from './third_party/index.js'; -import {puppeteer} from './third_party/index.js'; - -let browser: Browser | undefined; - -function makeTargetFilter() { - const ignoredPrefixes = new Set([ - 'chrome://', - 'chrome-extension://', - 'chrome-untrusted://', - ]); - - return function targetFilter(target: Target): boolean { - if (target.url() === 'chrome://newtab/') { - return true; - } - // Could be the only page opened in the browser. - if (target.url().startsWith('chrome://inspect')) { - return true; - } - for (const prefix of ignoredPrefixes) { - if (target.url().startsWith(prefix)) { - return false; + +// ── CDP Target Types ──────────────────────────────────── + +interface CdpTarget { + type: string; + title: string; + url: string; + webSocketDebuggerUrl: string; + id: string; +} + +interface CdpVersionInfo { + Browser: string; + [key: string]: unknown; +} + +// ── Module State (singleton — max one child at a time) ── + +let cdpWs: WebSocket | undefined; +let cdpPort: number | undefined; +let inspectorPort: number | undefined; +let hostBridgePath: string | undefined; +let devhostBridgePath: string | undefined; +let childProcess: ChildProcess | undefined; +let launcherPid: number | undefined; +let electronPid: number | undefined; +let userDataDir: string | undefined; +let connectInProgress: Promise | undefined; + +// ── Raw CDP Communication ─────────────────────────────── + +let cdpMessageId = 0; + +/** + * Send a CDP command over the raw WebSocket and wait for the matching response. + */ +export function sendCdp( + method: string, + params: Record = {}, + ws?: WebSocket, +): Promise { + const socket = ws ?? cdpWs; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.reject( + new Error('CDP WebSocket is not connected'), + ); + } + + return new Promise((resolve, reject) => { + const id = ++cdpMessageId; + const handler = (evt: WebSocket.MessageEvent) => { + const raw = typeof evt.data === 'string' ? evt.data : evt.data.toString(); + const data = JSON.parse(raw); + if (data.id === id) { + socket.removeEventListener('message', handler); + if (data.error) { + reject(new Error(`CDP ${method}: ${data.error.message}`)); + } else { + resolve(data.result); + } } + }; + socket.addEventListener('message', handler); + socket.send(JSON.stringify({id, method, params})); + }); +} + +// ── Public Getters ────────────────────────────────────── + +export function getCdpWebSocket(): WebSocket | undefined { + return cdpWs; +} + +export function getCdpPort(): number | undefined { + return cdpPort; +} + +export function getHostBridgePath(): string | undefined { + return hostBridgePath; +} + +export function getDevhostBridgePath(): string | undefined { + return devhostBridgePath; +} + +export function isConnected(): boolean { + return cdpWs?.readyState === WebSocket.OPEN; +} + +// ── Port Polling ──────────────────────────────────────── + +async function waitForDebugPort( + port: number, + timeout = 30_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (r.ok) { + return (await r.json()) as CdpVersionInfo; + } + } catch { + // Port not ready } - return true; - }; + await new Promise(r => setTimeout(r, 500)); + } + throw new Error( + `CDP port ${port} did not become available within ${timeout}ms.\n` + + 'Possible causes:\n' + + '- Firewall blocking the port\n' + + '- VS Code failed to start with debugging enabled\n' + + '- Another process claimed the port before VS Code', + ); } -export async function ensureBrowserConnected(options: { - browserURL?: string; - wsEndpoint?: string; - wsHeaders?: Record; - devtools: boolean; - channel?: Channel; - userDataDir?: string; -}) { - const {channel} = options; - if (browser?.connected) { - return browser; +async function waitForInspectorPort( + port: number, + timeout = 30_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const r = await fetch(`http://127.0.0.1:${port}/json`); + if (r.ok) { + return; + } + } catch { + // Port not ready + } + await new Promise(r => setTimeout(r, 300)); } + throw new Error( + `Inspector port ${port} did not become available within ${timeout}ms`, + ); +} - const connectOptions: Parameters[0] = { - targetFilter: makeTargetFilter(), - defaultViewport: null, - handleDevToolsAsPage: true, - }; +// ── PID Discovery ─────────────────────────────────────── - if (options.wsEndpoint) { - connectOptions.browserWSEndpoint = options.wsEndpoint; - if (options.wsHeaders) { - connectOptions.headers = options.wsHeaders; - } - } else if (options.browserURL) { - connectOptions.browserURL = options.browserURL; - } else if (channel || options.userDataDir) { - const userDataDir = options.userDataDir; - if (userDataDir) { - // TODO: re-expose this logic via Puppeteer. - const portPath = path.join(userDataDir, 'DevToolsActivePort'); - try { - const fileContent = await fs.promises.readFile(portPath, 'utf8'); - const [rawPort, rawPath] = fileContent - .split('\n') - .map(line => { - return line.trim(); - }) - .filter(line => { - return !!line; - }); - if (!rawPort || !rawPath) { - throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`); - } - const port = parseInt(rawPort, 10); - if (isNaN(port) || port <= 0 || port > 65535) { - throw new Error(`Invalid port '${rawPort}' found`); +/** + * Discover the PID of the process listening on the given port. + * + * On Windows: uses `netstat -ano` to find LISTENING pid on the CDP port. + * On Linux/macOS: uses `lsof -ti :port`. + * + * This is necessary because Code.exe on Windows is a launcher stub that + * forks the real Electron binary and exits. The launcher PID is useless + * for cleanup — we need the real Electron PID. + */ +async function discoverElectronPid(port: number): Promise { + try { + if (process.platform === 'win32') { + const out = execSync( + `netstat -ano | findstr "LISTENING" | findstr ":${port} "`, + {encoding: 'utf8', timeout: 5000}, + ).trim(); + // Lines look like: TCP 127.0.0.1:44131 0.0.0.0:0 LISTENING 12345 + for (const line of out.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 5) { + const pid = parseInt(parts[parts.length - 1], 10); + if (pid > 0) return pid; } - const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`; - connectOptions.browserWSEndpoint = browserWSEndpoint; - } catch (error) { - throw new Error( - `Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled.`, - { - cause: error, - }, - ); } } else { - if (!channel) { - throw new Error('Channel must be provided if userDataDir is missing'); - } - connectOptions.channel = ( - channel === 'stable' ? 'chrome' : `chrome-${channel}` - ) as ChromeReleaseChannel; + const out = execSync(`lsof -ti :${port}`, { + encoding: 'utf8', + timeout: 5000, + }).trim(); + const pid = parseInt(out.split('\n')[0], 10); + if (pid > 0) return pid; } - } else { - throw new Error( - 'Either browserURL, wsEndpoint, channel or userDataDir must be provided', - ); + } catch { + // Command failed — maybe no process or tool not available } + return null; +} - logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); - try { - browser = await puppeteer.connect(connectOptions); - } catch (err) { +// ── Target Discovery ──────────────────────────────────── + +/** + * Query CDP /json/list and find the VS Code workbench page target. + * Returns the page-level webSocketDebuggerUrl (NOT the browser endpoint). + */ +async function findWorkbenchTarget(port: number): Promise { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + const targets = (await response.json()) as CdpTarget[]; + + let workbench = targets.find( + t => t.type === 'page' && t.title.includes('Visual Studio Code'), + ); + + if (!workbench) { + workbench = targets.find(t => t.type === 'page'); + if (workbench) { + logger( + `No "Visual Studio Code" title found, using first page: "${workbench.title}"`, + ); + } + } + + if (!workbench) { throw new Error( - 'Could not connect to Chrome. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.', - { - cause: err, - }, + `Could not find VS Code workbench target among ${targets.length} targets.\n` + + `Available: ${targets.map(t => `${t.type}: ${t.title}`).join(', ')}\n` + + 'The debug window may have opened to an unexpected state.', ); } - logger('Connected Puppeteer'); - return browser; -} - -interface McpLaunchOptions { - acceptInsecureCerts?: boolean; - executablePath?: string; - channel?: Channel; - userDataDir?: string; - headless: boolean; - isolated: boolean; - logFile?: fs.WriteStream; - viewport?: { - width: number; - height: number; - }; - chromeArgs?: string[]; - ignoreDefaultChromeArgs?: string[]; - devtools: boolean; - enableExtensions?: boolean; -} - -export async function launch(options: McpLaunchOptions): Promise { - const {channel, executablePath, headless, isolated} = options; - const profileDirName = - channel && channel !== 'stable' - ? `chrome-profile-${channel}` - : 'chrome-profile'; - - let userDataDir = options.userDataDir; - if (!isolated && !userDataDir) { - userDataDir = path.join( - os.homedir(), - '.cache', - 'chrome-devtools-mcp', - profileDirName, - ); - await fs.promises.mkdir(userDataDir, { - recursive: true, - }); + + return workbench; +} + +// ── WebSocket Connection ──────────────────────────────── + +async function connectCdpWebSocket(wsUrl: string): Promise { + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = (err: WebSocket.ErrorEvent) => + reject(new Error(`CDP WebSocket error: ${err.message}`)); + }); + return ws; +} + +// ── Workbench Readiness ───────────────────────────────── + +async function waitForWorkbenchReady( + ws: WebSocket, + timeout = 30_000, +): Promise { + logger('Waiting for VS Code workbench to finish loading...'); + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const result = await sendCdp( + 'Runtime.evaluate', + { + expression: `(() => { + const hasMonaco = !!document.querySelector('.monaco-workbench'); + const readyState = document.readyState; + return JSON.stringify({ hasMonaco, readyState }); + })()`, + returnByValue: true, + }, + ws, + ); + const state = JSON.parse(result.result.value); + if (state.hasMonaco && state.readyState === 'complete') { + logger('Workbench ready'); + return; + } + logger( + `Not ready yet: hasMonaco=${state.hasMonaco}, readyState=${state.readyState}`, + ); + } catch { + // Page may not be ready for evaluate yet + } + await new Promise(r => setTimeout(r, 1000)); } + logger('Warning: timed out waiting for workbench — proceeding anyway'); +} - const args: LaunchOptions['args'] = [ - ...(options.chromeArgs ?? []), - '--hide-crash-restore-bubble', - ]; - const ignoreDefaultArgs: LaunchOptions['ignoreDefaultArgs'] = - options.ignoreDefaultChromeArgs ?? false; +// ── Dev Host Bridge Discovery ─────────────────────────── - if (headless) { - args.push('--screen-info={3840x2160}'); +/** + * Wait for extension-bridge to activate in the Extension Dev Host. + * The Dev Host writes its own sockpath marker to the target folder. + */ +async function waitForDevHostBridge( + targetFolder: string, + timeout = 15_000, +): Promise { + const markerPath = path.join( + targetFolder, + '.vscode', + 'vscode-api-expose.sockpath', + ); + const start = Date.now(); + while (Date.now() - start < timeout) { + if (fs.existsSync(markerPath)) { + const bridgePath = fs.readFileSync(markerPath, 'utf8').trim(); + if (bridgePath) { + logger(`Dev Host bridge discovered: ${bridgePath}`); + return bridgePath; + } + } + await new Promise(r => setTimeout(r, 500)); } - let puppeteerChannel: ChromeReleaseChannel | undefined; - if (options.devtools) { - args.push('--auto-open-devtools-for-tabs'); + logger('Dev Host bridge not found within timeout'); + return null; +} + +// ── Synchronous Child Kill ────────────────────────────── + +/** + * Force-kill the child process tree synchronously. + * + * On Windows: `Code.exe` is a launcher stub that forks the real Electron + * binary and exits (code 9). The launcher PID is dead by the time CDP is + * available. We track the REAL Electron PID (discovered from `netstat` after + * the CDP port opens) and kill its entire process tree with `taskkill /F /T`. + * + * Safe to call multiple times or when no child exists. + */ +function forceKillChildSync(): void { + // Kill the real Electron process (the one actually running VS Code) + const ePid = electronPid; + if (ePid) { + try { + if (process.platform === 'win32') { + execSync(`taskkill /F /T /PID ${ePid}`, {stdio: 'ignore'}); + } else { + process.kill(ePid, 'SIGKILL'); + } + } catch { + // Process already exited + } } - if (!executablePath) { - puppeteerChannel = - channel && channel !== 'stable' - ? (`chrome-${channel}` as ChromeReleaseChannel) - : 'chrome'; + + // Also try the launcher PID (may still be alive on non-Windows) + const lPid = launcherPid; + if (lPid && lPid !== ePid) { + try { + if (process.platform === 'win32') { + execSync(`taskkill /F /T /PID ${lPid}`, {stdio: 'ignore'}); + } else { + process.kill(lPid, 'SIGKILL'); + } + } catch { + // Process already exited + } } + childProcess = undefined; + launcherPid = undefined; + electronPid = undefined; +} + +/** + * Kill any existing child, close WS, and clean up temp dir. + * Synchronous except for the WS close (best-effort). + */ +function teardownSync(): void { try { - const browser = await puppeteer.launch({ - channel: puppeteerChannel, - targetFilter: makeTargetFilter(), - executablePath, - defaultViewport: null, - userDataDir, - pipe: true, - headless, - args, - ignoreDefaultArgs: ignoreDefaultArgs, - acceptInsecureCerts: options.acceptInsecureCerts, - handleDevToolsAsPage: true, - enableExtensions: options.enableExtensions, - }); - if (options.logFile) { - // FIXME: we are probably subscribing too late to catch startup logs. We - // should expose the process earlier or expose the getRecentLogs() getter. - browser.process()?.stderr?.pipe(options.logFile); - browser.process()?.stdout?.pipe(options.logFile); - } - if (options.viewport) { - const [page] = await browser.pages(); - await page?.resize({ - contentWidth: options.viewport.width, - contentHeight: options.viewport.height, - }); - } - return browser; - } catch (error) { - if ( - userDataDir && - (error as Error).message.includes('The browser is already running') - ) { - throw new Error( - `The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, - { - cause: error, - }, - ); + cdpWs?.close(); + } catch { + // best-effort + } + cdpWs = undefined; + + forceKillChildSync(); + + if (userDataDir) { + try { + fs.rmSync(userDataDir, {recursive: true, force: true}); + } catch { + // best-effort } - throw error; + userDataDir = undefined; + } +} + +// ── Lifecycle Handlers (registered once) ──────────────── + +/** + * Registers process-level handlers that guarantee the child is killed + * on ANY exit path — clean, signal, or crash. + * + * On Windows, VS Code kills the MCP server by closing stdin and terminating + * the process. We listen for 'end' on stdin as the PRIMARY shutdown trigger, + * PLUS `process.on('exit')` as a synchronous last-resort safety net. + */ +function registerLifecycleHandlers(): void { + // Primary: stdin 'end' fires when VS Code disconnects the MCP server. + // This is the most reliable signal on Windows. + process.stdin.on('end', () => { + logger('stdin ended — killing child process'); + forceKillChildSync(); + process.exit(0); + }); + + // Synchronous safety net — guaranteed to fire on any exit path + process.on('exit', () => { + forceKillChildSync(); + }); + + // Graceful signal handlers — try async cleanup first, then exit + const gracefulShutdown = async () => { + await stopDebugWindow(); + process.exit(0); + }; + + process.on('SIGINT', gracefulShutdown); + process.on('SIGTERM', gracefulShutdown); + + process.on('uncaughtException', async (err) => { + logger('Uncaught exception:', err); + await stopDebugWindow(); + process.exit(1); + }); +} + +// Register once at module load — no flag needed +registerLifecycleHandlers(); + +// ── Main Entry Point ──────────────────────────────────── + +export interface VSCodeLaunchOptions { + workspaceFolder: string; + extensionBridgePath: string; + targetFolder?: string; + headless?: boolean; +} + +/** + * Ensure the VS Code debug window is spawned and CDP is connected. + * + * SINGLETON: If a healthy connection exists, returns it immediately. + * If a stale child exists (WS dead), kills it before respawning. + * Concurrent callers are gated — only one spawn runs at a time. + * + * Steps: + * 1. Discovers Host bridge via sockpath marker + * 2. Allocates dynamic ports (CDP + inspector) + * 3. Spawns Extension Development Host with extension-bridge + * 4. Attaches debugger for full debug UI + * 5. Connects raw CDP WebSocket to workbench page + * 6. Polls for workbench readiness + */ +export async function ensureVSCodeConnected( + options: VSCodeLaunchOptions, +): Promise { + // Fast path: healthy connection — reuse it + if (cdpWs?.readyState === WebSocket.OPEN) { + return cdpWs; + } + + // Gate: if another connect is already in-flight, wait for it + if (connectInProgress) { + return connectInProgress; + } + + connectInProgress = doConnect(options); + try { + return await connectInProgress; + } finally { + connectInProgress = undefined; } } -export async function ensureBrowserLaunched( - options: McpLaunchOptions, -): Promise { - if (browser?.connected) { - return browser; +async function doConnect(options: VSCodeLaunchOptions): Promise { + // Kill any stale child before spawning a new one — no duplicates + teardownSync(); + + // 1. Discover Host bridge + hostBridgePath = discoverBridgePath(options.workspaceFolder); + + // 2. Get Electron executable path from the Host VS Code + const electronPath = (await bridgeExec( + hostBridgePath, + 'return process.execPath;', + )) as string; + logger(`Electron executable: ${electronPath}`); + + // 3. Allocate dynamic ports + const cPort = await getPort(); + const iPort = await getPort(); + cdpPort = cPort; + inspectorPort = iPort; + logger(`Allocated CDP port: ${cPort}, inspector port: ${iPort}`); + + // 4. Create unique temp user-data-dir (MANDATORY for separate instance) + userDataDir = path.join(os.tmpdir(), `vscode-mcp-${Date.now()}`); + fs.mkdirSync(userDataDir, {recursive: true}); + logger(`User data dir: ${userDataDir}`); + + // 5. Spawn Extension Development Host + // `detached: true` is REQUIRED on Windows because Code.exe is a launcher + // stub that forks the real Electron binary and immediately exits (code 9). + // Without detached, the forked Electron process would be killed. + // We do NOT call unref() — Node keeps a reference so it doesn't exit early. + const targetFolder = options.targetFolder ?? options.workspaceFolder; + const args = [ + `--remote-debugging-port=${cPort}`, + `--inspect-extensions=${iPort}`, + `--extensionDevelopmentPath=${options.extensionBridgePath}`, + `--user-data-dir=${userDataDir}`, + '--new-window', + '--skip-release-notes', + '--skip-welcome', + targetFolder, + ]; + + logger(`Spawning Extension Development Host: ${electronPath} ${args.join(' ')}`); + // Strip ELECTRON_RUN_AS_NODE from the child's environment. + // VS Code sets this when spawning MCP servers so that Code.exe acts as Node. + // The child VS Code instance must NOT inherit it — it needs to run as Electron. + const childEnv = {...process.env}; + delete childEnv.ELECTRON_RUN_AS_NODE; + delete childEnv.ELECTRON_NO_ASAR; // Also strip this Electron override + const proc = spawn(electronPath, args, { + detached: true, + stdio: 'ignore', + env: childEnv, + }); + proc.unref(); + childProcess = proc; + launcherPid = proc.pid; + logger(`Launcher spawned — PID: ${launcherPid} (may exit immediately on Windows)`); + + // Track launcher exit (on Windows this fires almost immediately with code=9) + proc.on('exit', (code, signal) => { + logger(`Launcher process exited: code=${code}, signal=${signal}`); + if (childProcess === proc) { + childProcess = undefined; + launcherPid = undefined; + } + }); + + // 6. Wait for CDP port + const versionInfo = await waitForDebugPort(cPort); + logger(`CDP available: ${versionInfo.Browser}`); + + // 6b. Discover the REAL Electron PID from the CDP port. + // On Windows, Code.exe (launcher) exits immediately — the real Electron + // process is the one actually listening on the CDP port. + const realPid = await discoverElectronPid(cPort); + if (realPid) { + electronPid = realPid; + logger(`Real Electron PID: ${electronPid}`); + } else { + logger('Warning: could not discover Electron PID — cleanup may be incomplete'); + } + + // 7. Wait for inspector, then attach debugger for full debug UI + try { + await waitForInspectorPort(iPort); + await bridgeAttachDebugger( + hostBridgePath, + iPort, + `Extension Host (port ${iPort})`, + ); + logger('Debug session attached — full debug UI active'); + } catch (err) { + logger( + `Warning: debugger attach failed: ${(err as Error).message}. Continuing without debug UI.`, + ); } - browser = await launch(options); - return browser; + + // 8. Find workbench page target and connect raw CDP WebSocket + const workbench = await findWorkbenchTarget(cPort); + logger( + `Connecting to workbench target: "${workbench.title}" (${workbench.webSocketDebuggerUrl})`, + ); + cdpWs = await connectCdpWebSocket(workbench.webSocketDebuggerUrl); + + // 9. Enable CDP domains and wait for readiness + await sendCdp('Runtime.enable', {}, cdpWs); + await sendCdp('Page.enable', {}, cdpWs); + await waitForWorkbenchReady(cdpWs); + + // 10. Discover Dev Host bridge (for VS Code API calls in the target window) + devhostBridgePath = (await waitForDevHostBridge(targetFolder)) ?? undefined; + + // Monitor for unexpected disconnects + cdpWs.on('close', () => { + logger('CDP WebSocket closed unexpectedly'); + cdpWs = undefined; + }); + + return cdpWs; } -export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; +// ── Public Cleanup ────────────────────────────────────── + +/** + * Graceful cleanup: detach debugger, close WS, kill child, remove temp dir. + * stopDebugging() only detaches — process.kill() is required to close the window + * since the process was spawned externally, not via VS Code's launch lifecycle. + */ +export async function stopDebugWindow(): Promise { + try { + cdpWs?.close(); + } catch { + // best-effort + } + + if (hostBridgePath) { + try { + await bridgeExec(hostBridgePath, 'await vscode.debug.stopDebugging();'); + } catch { + // best-effort + } + } + + forceKillChildSync(); + + if (userDataDir && fs.existsSync(userDataDir)) { + try { + fs.rmSync(userDataDir, {recursive: true, force: true}); + } catch { + // best-effort + } + } + + cdpWs = undefined; + cdpPort = undefined; + inspectorPort = undefined; + hostBridgePath = undefined; + devhostBridgePath = undefined; + childProcess = undefined; + launcherPid = undefined; + electronPid = undefined; + userDataDir = undefined; +} diff --git a/src/cli.ts b/src/cli.ts index 8815e2246..d7ff2d0f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,153 +8,37 @@ import type {YargsOptions} from './third_party/index.js'; import {yargs, hideBin} from './third_party/index.js'; export const cliOptions = { - autoConnect: { - type: 'boolean', - description: - 'If specified, automatically connects to a browser (Chrome 144+) running in the user data directory identified by the channel param. Requires the remoted debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.', - conflicts: ['isolated', 'executablePath'], - default: false, - coerce: (value: boolean | undefined) => { - if (!value) { - return; - } - return value; - }, - }, - browserUrl: { + folder: { type: 'string', description: - 'Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.', - alias: 'u', - conflicts: 'wsEndpoint', - coerce: (url: string | undefined) => { - if (!url) { - return; - } - try { - new URL(url); - } catch { - throw new Error(`Provided browserUrl ${url} is not valid URL.`); - } - return url; - }, + 'Path to workspace folder to open in VS Code. The MCP server will spawn a VS Code Extension Development Host window targeting this folder.', + alias: 'f', }, - wsEndpoint: { + extensionBridgePath: { type: 'string', description: - 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/). Alternative to --browserUrl.', - alias: 'w', - conflicts: 'browserUrl', - coerce: (url: string | undefined) => { - if (!url) { - return; - } - try { - const parsed = new URL(url); - if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { - throw new Error( - `Provided wsEndpoint ${url} must use ws:// or wss:// protocol.`, - ); - } - return url; - } catch (error) { - if ((error as Error).message.includes('ws://')) { - throw error; - } - throw new Error(`Provided wsEndpoint ${url} is not valid URL.`); - } - }, + 'Path to the extension-bridge extension directory. Defaults to the extension-bridge folder adjacent to the vscode-devtools-mcp package.', + alias: 'b', }, - wsHeaders: { + targetFolder: { type: 'string', description: - 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --wsEndpoint.', - implies: 'wsEndpoint', - coerce: (val: string | undefined) => { - if (!val) { - return; - } - try { - const parsed = JSON.parse(val); - if (typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Headers must be a JSON object'); - } - return parsed as Record; - } catch (error) { - throw new Error( - `Invalid JSON for wsHeaders: ${(error as Error).message}`, - ); - } - }, + 'Path to a folder to open in the Extension Development Host. If not specified, the workspace folder is used.', + alias: 't', }, headless: { type: 'boolean', - description: 'Whether to run in headless (no UI) mode.', + description: 'Run VS Code headless (requires xvfb on Linux).', default: false, }, - executablePath: { - type: 'string', - description: 'Path to custom Chrome executable.', - conflicts: ['browserUrl', 'wsEndpoint'], - alias: 'e', - }, - isolated: { - type: 'boolean', - description: - 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.', - }, - userDataDir: { - type: 'string', - description: - 'Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE', - conflicts: ['browserUrl', 'wsEndpoint', 'isolated'], - }, - channel: { - type: 'string', - description: - 'Specify a different Chrome channel that should be used. The default is the stable channel version.', - choices: ['stable', 'canary', 'beta', 'dev'] as const, - conflicts: ['browserUrl', 'wsEndpoint', 'executablePath'], - }, logFile: { type: 'string', describe: - 'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.', - }, - viewport: { - type: 'string', - describe: - 'Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.', - coerce: (arg: string | undefined) => { - if (arg === undefined) { - return; - } - const [width, height] = arg.split('x').map(Number); - if (!width || !height || Number.isNaN(width) || Number.isNaN(height)) { - throw new Error('Invalid viewport. Expected format is `1280x720`.'); - } - return { - width, - height, - }; - }, - }, - proxyServer: { - type: 'string', - description: `Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.`, - }, - acceptInsecureCerts: { - type: 'boolean', - description: `If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.`, - }, - experimentalDevtools: { - type: 'boolean', - describe: 'Whether to enable automation over DevTools targets', - hidden: true, + 'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs.', }, experimentalVision: { type: 'boolean', - describe: 'Whether to enable vision tools', + describe: 'Whether to enable vision tools.', hidden: true, }, experimentalStructuredContent: { @@ -162,32 +46,13 @@ export const cliOptions = { describe: 'Whether to output structured formatted content.', hidden: true, }, - experimentalIncludeAllPages: { + devDiagnostic: { type: 'boolean', describe: - 'Whether to include all kinds of pages such as webviews or background pages as pages.', - hidden: true, - }, - experimentalInteropTools: { - type: 'boolean', - describe: 'Whether to enable interoperability tools', + 'Enable diagnostic development tools (debug_evaluate, debug_bridge_exec). Hidden in production.', + default: false, hidden: true, }, - chromeArg: { - type: 'array', - describe: - 'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.', - }, - ignoreDefaultChromeArg: { - type: 'array', - describe: - 'Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.', - }, - categoryEmulation: { - type: 'boolean', - default: true, - describe: 'Set to false to exclude tools related to emulation.', - }, categoryPerformance: { type: 'boolean', default: true, @@ -198,114 +63,30 @@ export const cliOptions = { default: true, describe: 'Set to false to exclude tools related to network.', }, - categoryExtensions: { - type: 'boolean', - default: false, - hidden: true, - describe: 'Set to false to exclude tools related to extensions.', - }, - performanceCrux: { - type: 'boolean', - default: true, - describe: - 'Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.', - }, - usageStatistics: { - type: 'boolean', - default: true, - describe: - 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.', - }, - clearcutEndpoint: { - type: 'string', - hidden: true, - describe: 'Endpoint for Clearcut telemetry.', - }, - clearcutForceFlushIntervalMs: { - type: 'number', - hidden: true, - describe: 'Force flush interval in milliseconds (for testing).', - }, - clearcutIncludePidHeader: { - type: 'boolean', - hidden: true, - describe: 'Include watchdog PID in Clearcut request headers (for testing).', - }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { const yargsInstance = yargs(hideBin(argv)) - .scriptName('npx chrome-devtools-mcp@latest') + .scriptName('npx vscode-devtools-mcp@latest') .options(cliOptions) - .check(args => { - // We can't set default in the options else - // Yargs will complain - if ( - !args.channel && - !args.browserUrl && - !args.wsEndpoint && - !args.executablePath - ) { - args.channel = 'stable'; - } - return true; - }) .example([ [ - '$0 --browserUrl http://127.0.0.1:9222', - 'Connect to an existing browser instance via HTTP', + '$0 --folder /path/to/project', + 'Spawn a VS Code debug window for the project folder', ], - [ - '$0 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123', - 'Connect to an existing browser instance via WebSocket', - ], - [ - `$0 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123 --wsHeaders '{"Authorization":"Bearer token"}'`, - 'Connect via WebSocket with custom headers', - ], - ['$0 --channel beta', 'Use Chrome Beta installed on this system'], - ['$0 --channel canary', 'Use Chrome Canary installed on this system'], - ['$0 --channel dev', 'Use Chrome Dev installed on this system'], - ['$0 --channel stable', 'Use stable Chrome installed on this system'], + ['$0 --headless', 'Run VS Code in headless mode (Linux only)'], ['$0 --logFile /tmp/log.txt', 'Save logs to a file'], - ['$0 --help', 'Print CLI options'], - [ - '$0 --viewport 1280x720', - 'Launch Chrome with the initial viewport size of 1280x720px', - ], - [ - `$0 --chrome-arg='--no-sandbox' --chrome-arg='--disable-setuid-sandbox'`, - 'Launch Chrome without sandboxes. Use with caution.', - ], [ - `$0 --ignore-default-chrome-arg='--disable-extensions'`, - 'Disable the default arguments provided by Puppeteer. Use with caution.', + '$0 --dev-diagnostic', + 'Enable diagnostic tools for development debugging', ], - ['$0 --no-category-emulation', 'Disable tools in the emulation category'], [ '$0 --no-category-performance', 'Disable tools in the performance category', ], - ['$0 --no-category-network', 'Disable tools in the network category'], - [ - '$0 --user-data-dir=/tmp/user-data-dir', - 'Use a custom user data directory', - ], - [ - '$0 --auto-connect', - 'Connect to a stable Chrome instance (Chrome 144+) running instead of launching a new instance', - ], - [ - '$0 --auto-connect --channel=canary', - 'Connect to a canary Chrome instance (Chrome 144+) running instead of launching a new instance', - ], - [ - '$0 --no-usage-statistics', - 'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.', - ], [ - '$0 --no-performance-crux', - 'Disable CrUX (field data) integration in performance tools.', + '$0 --no-category-network', + 'Disable tools in the network category', ], ]); diff --git a/src/main.ts b/src/main.ts index 955d173e2..d7a452e92 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,17 +8,13 @@ import './polyfill.js'; import process from 'node:process'; -import type {Channel} from './browser.js'; -import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; -import {cliOptions, parseArguments} from './cli.js'; +import {ensureVSCodeConnected} from './browser.js'; +import {parseArguments} from './cli.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; import {McpContext} from './McpContext.js'; import {McpResponse} from './McpResponse.js'; import {Mutex} from './Mutex.js'; -import {ClearcutLogger} from './telemetry/clearcut-logger.js'; -import {computeFlagUsage} from './telemetry/flag-utils.js'; -import {bucketizeLatency} from './telemetry/metric-utils.js'; import { McpServer, StdioServerTransport, @@ -36,37 +32,19 @@ const VERSION = '0.16.0'; export const args = parseArguments(VERSION); -const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; -if ( - process.env['CI'] || - process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] -) { - console.error( - "turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.", - ); - args.usageStatistics = false; -} - -let clearcutLogger: ClearcutLogger | undefined; -if (args.usageStatistics) { - clearcutLogger = new ClearcutLogger({ - logFile: args.logFile, - appVersion: VERSION, - clearcutEndpoint: args.clearcutEndpoint, - clearcutForceFlushIntervalMs: args.clearcutForceFlushIntervalMs, - clearcutIncludePidHeader: args.clearcutIncludePidHeader, - }); +if (args.logFile) { + saveLogsToFile(args.logFile); } process.on('unhandledRejection', (reason, promise) => { logger('Unhandled promise rejection', promise, reason); }); -logger(`Starting Chrome DevTools MCP Server v${VERSION}`); +logger(`Starting VS Code DevTools MCP Server v${VERSION}`); const server = new McpServer( { - name: 'chrome_devtools', - title: 'Chrome DevTools MCP server', + name: 'vscode_devtools', + title: 'VS Code DevTools MCP server', version: VERSION, }, {capabilities: {logging: {}}}, @@ -75,47 +53,31 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => { return {}; }); -let context: McpContext; -async function getContext(): Promise { - const chromeArgs: string[] = (args.chromeArg ?? []).map(String); - const ignoreDefaultChromeArgs: string[] = ( - args.ignoreDefaultChromeArg ?? [] - ).map(String); - if (args.proxyServer) { - chromeArgs.push(`--proxy-server=${args.proxyServer}`); - } - const devtools = args.experimentalDevtools ?? false; - const browser = - args.browserUrl || args.wsEndpoint || args.autoConnect - ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - wsEndpoint: args.wsEndpoint, - wsHeaders: args.wsHeaders, - // Important: only pass channel, if autoConnect is true. - channel: args.autoConnect ? (args.channel as Channel) : undefined, - userDataDir: args.userDataDir, - devtools, - }) - : await ensureBrowserLaunched({ - headless: args.headless, - executablePath: args.executablePath, - channel: args.channel as Channel, - isolated: args.isolated ?? false, - userDataDir: args.userDataDir, - logFile, - viewport: args.viewport, - chromeArgs, - ignoreDefaultChromeArgs, - acceptInsecureCerts: args.acceptInsecureCerts, - devtools, - enableExtensions: args.categoryExtensions, - }); +let context: McpContext | undefined; - if (context?.browser !== browser) { - context = await McpContext.from(browser, logger, { - experimentalDevToolsDebugging: devtools, - experimentalIncludeAllPages: args.experimentalIncludeAllPages, - performanceCrux: args.performanceCrux, +/** + * Ensure VS Code debug window is connected (CDP + bridge). + * Required for ALL tools including diagnostic ones. + */ +async function ensureConnection(): Promise { + await ensureVSCodeConnected({ + workspaceFolder: args.folder as string, + extensionBridgePath: args.extensionBridgePath as string, + targetFolder: args.targetFolder as string | undefined, + headless: args.headless, + }); +} + +/** + * Get or create the McpContext (Puppeteer page model). + * Only needed for non-diagnostic tools that interact via Puppeteer. + * Phase B will refactor this to work without a Puppeteer Browser. + */ +async function getContext(): Promise { + if (!context) { + context = await McpContext.from(undefined as never, logger, { + experimentalDevToolsDebugging: false, + performanceCrux: false, }); } return context; @@ -123,35 +85,15 @@ async function getContext(): Promise { const logDisclaimers = () => { console.error( - `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, -debug, and modify any data in the browser or DevTools. + `vscode-devtools-mcp exposes content of the VS Code debug window to MCP clients, +allowing them to inspect, debug, and modify any data visible in the editor. Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, ); - - if (args.performanceCrux) { - console.error( - `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`, - ); - } - - if (args.usageStatistics) { - console.error( - ` -Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics. -For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`, - ); - } }; const toolMutex = new Mutex(); function registerTool(tool: ToolDefinition): void { - if ( - tool.annotations.category === ToolCategory.EMULATION && - args.categoryEmulation === false - ) { - return; - } if ( tool.annotations.category === ToolCategory.PERFORMANCE && args.categoryPerformance === false @@ -164,21 +106,16 @@ function registerTool(tool: ToolDefinition): void { ) { return; } - if ( - tool.annotations.category === ToolCategory.EXTENSIONS && - args.categoryExtensions === false - ) { - return; - } if ( tool.annotations.conditions?.includes('computerVision') && !args.experimentalVision ) { return; } + // Hide diagnostic tools in production unless explicitly enabled if ( - tool.annotations.conditions?.includes('experimentalInteropTools') && - !args.experimentalInteropTools + tool.annotations.conditions?.includes('devDiagnostic') && + !args.devDiagnostic ) { return; } @@ -191,31 +128,48 @@ function registerTool(tool: ToolDefinition): void { }, async (params): Promise => { const guard = await toolMutex.acquire(); - const startTime = Date.now(); - let success = false; try { logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); - const context = await getContext(); + + // Always ensure VS Code connection (CDP + bridge) + await ensureConnection(); + + // Diagnostic tools bypass McpContext — they use sendCdp/bridgeExec directly. + // Non-diagnostic tools need full McpContext (Phase B will refactor this). + const isDiagnostic = tool.annotations.conditions?.includes('devDiagnostic'); + const ctx = isDiagnostic ? (undefined as never) : await getContext(); + logger(`${tool.name} context: resolved`); - await context.detectOpenDevToolsWindows(); const response = new McpResponse(); await tool.handler( { params, }, response, - context, + ctx, ); + + // Diagnostic tools return content directly without McpResponse.handle() + if (isDiagnostic) { + const textContent: Array<{type: 'text'; text: string}> = []; + for (const line of response.responseLines) { + textContent.push({type: 'text', text: line}); + } + if (textContent.length === 0) { + textContent.push({type: 'text', text: '(no output)'}); + } + return {content: textContent}; + } + const {content, structuredContent} = await response.handle( tool.name, - context, + ctx, ); const result: CallToolResult & { structuredContent?: Record; } = { content, }; - success = true; if (args.experimentalStructuredContent) { result.structuredContent = structuredContent as Record< string, @@ -239,11 +193,6 @@ function registerTool(tool: ToolDefinition): void { isError: true, }; } finally { - void clearcutLogger?.logToolInvocation({ - toolName: tool.name, - success, - latencyMs: bucketizeLatency(Date.now() - startTime), - }); guard.dispose(); } }, @@ -257,7 +206,5 @@ for (const tool of tools) { await loadIssueDescriptions(); const transport = new StdioServerTransport(); await server.connect(transport); -logger('Chrome DevTools MCP Server connected'); +logger('VS Code DevTools MCP Server connected'); logDisclaimers(); -void clearcutLogger?.logDailyActiveIfNeeded(); -void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions)); diff --git a/src/tools/categories.ts b/src/tools/categories.ts index 9e3512689..8e3cf9376 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -7,19 +7,21 @@ export enum ToolCategory { INPUT = 'input', NAVIGATION = 'navigation', - EMULATION = 'emulation', PERFORMANCE = 'performance', NETWORK = 'network', DEBUGGING = 'debugging', - EXTENSIONS = 'extensions', + EDITOR_TABS = 'editor_tabs', + UI_CONTEXT = 'ui_context', + DEV_DIAGNOSTICS = 'dev_diagnostics', } export const labels = { [ToolCategory.INPUT]: 'Input automation', [ToolCategory.NAVIGATION]: 'Navigation automation', - [ToolCategory.EMULATION]: 'Emulation', [ToolCategory.PERFORMANCE]: 'Performance', [ToolCategory.NETWORK]: 'Network', [ToolCategory.DEBUGGING]: 'Debugging', - [ToolCategory.EXTENSIONS]: 'Extensions', + [ToolCategory.EDITOR_TABS]: 'Editor tabs', + [ToolCategory.UI_CONTEXT]: 'UI context', + [ToolCategory.DEV_DIAGNOSTICS]: 'Development diagnostics', }; diff --git a/src/tools/debug-bridge-exec.ts b/src/tools/debug-bridge-exec.ts new file mode 100644 index 000000000..cc18c7856 --- /dev/null +++ b/src/tools/debug-bridge-exec.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Development diagnostic tool: execute arbitrary VS Code API code via the + * extension-bridge named pipe/socket. + * + * Hidden in production — kept for development and troubleshooting. + * Gives direct access to the VS Code extension API context (vscode namespace). + * + * The code runs inside `new Function('vscode', 'payload', ...)` in the + * extension host process. `require()` is NOT available — only `vscode` API + * and `payload` are in scope. + */ + +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; +import {bridgeExec} from '../bridge-client.js'; +import {getHostBridgePath, getDevhostBridgePath} from '../browser.js'; + +export const debugBridgeExec = defineTool({ + name: 'debug_bridge_exec', + description: `[DEV] Execute arbitrary VS Code API code via the extension-bridge. +The code runs in a \`new Function('vscode', 'payload', ...)\` context inside the +extension host process. \`require()\` is NOT available. + +Use 'host' target for the controller VS Code, or 'devhost' for the spawned +Extension Development Host window. + +Examples: +- \`return vscode.version;\` — get VS Code version +- \`return vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath);\` — list workspace folders +- \`return vscode.window.tabGroups.all.flatMap(g => g.tabs.map(t => ({label: t.label, active: t.isActive})));\` — list editor tabs +- \`const editor = vscode.window.activeTextEditor; return editor ? { file: editor.document.fileName, line: editor.selection.active.line } : null;\` — get active editor info +- \`return vscode.extensions.all.filter(e => e.isActive).map(e => e.id);\` — list active extensions`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + conditions: ['devDiagnostic'], + }, + schema: { + code: zod + .string() + .describe( + 'VS Code API code to execute. Must use `return` to return a value. ' + + 'Runs inside an async function body, so `await` is available. ' + + '`vscode` and `payload` are in scope. `require()` is NOT available.', + ), + target: zod + .enum(['host', 'devhost']) + .optional() + .default('devhost') + .describe( + 'Which VS Code instance to target. ' + + '"host" = the controller VS Code with extension-bridge. ' + + '"devhost" = the spawned Extension Development Host window. ' + + 'Default: "devhost".', + ), + payload: zod + .unknown() + .optional() + .describe( + 'Optional JSON-serializable payload passed as the `payload` parameter.', + ), + }, + handler: async (request, response) => { + const {code, target, payload} = request.params; + + const bridgePath = + target === 'host' ? getHostBridgePath() : getDevhostBridgePath(); + + if (!bridgePath) { + const targetLabel = + target === 'host' ? 'Host VS Code' : 'Extension Development Host'; + response.appendResponseLine( + `**Error:** ${targetLabel} bridge is not connected.\n` + + 'Ensure the VS Code debug window has been launched and extension-bridge is active.', + ); + return; + } + + try { + const result = await bridgeExec(bridgePath, code, payload); + response.appendResponseLine('**Result:**'); + response.appendResponseLine('```json'); + response.appendResponseLine( + typeof result === 'string' + ? result + : JSON.stringify(result, null, 2), + ); + response.appendResponseLine('```'); + } catch (err) { + response.appendResponseLine('**Bridge exec error:**'); + response.appendResponseLine('```'); + response.appendResponseLine((err as Error).message); + response.appendResponseLine('```'); + } + }, +}); diff --git a/src/tools/debug-evaluate.ts b/src/tools/debug-evaluate.ts new file mode 100644 index 000000000..f24c1bf17 --- /dev/null +++ b/src/tools/debug-evaluate.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Development diagnostic tool: execute arbitrary JavaScript in the VS Code + * workbench renderer via CDP Runtime.evaluate. + * + * Hidden in production — kept for development and troubleshooting. + * Gives direct access to the Electron renderer context (DOM, window, document). + */ + +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; +import {sendCdp} from '../browser.js'; + +export const debugEvaluate = defineTool({ + name: 'debug_evaluate', + description: `[DEV] Execute arbitrary JavaScript in the VS Code workbench renderer context via CDP Runtime.evaluate. +Returns the result as JSON. Use for inspecting DOM state, console output, window properties, +or any renderer-side diagnostics. The expression runs in the Electron renderer process context +(document, window, etc.). + +Examples: +- \`document.title\` — get window title +- \`document.querySelector('.monaco-workbench')?.className\` — check workbench state +- \`JSON.stringify(performance.getEntriesByType('navigation'))\` — navigation timing +- \`Array.from(document.querySelectorAll('.notification-toast')).map(n => n.textContent)\` — list notifications`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + conditions: ['devDiagnostic'], + }, + schema: { + expression: zod + .string() + .describe( + 'JavaScript expression to evaluate in the VS Code renderer context. ' + + 'Must be a valid expression (not a statement). For multi-line logic, ' + + 'wrap in an IIFE: `(() => { ... })()`.', + ), + returnByValue: zod + .boolean() + .optional() + .default(true) + .describe( + 'Whether to return the result by value (serialized). Default true.', + ), + }, + handler: async (request, response) => { + const {expression, returnByValue} = request.params; + + const result = await sendCdp('Runtime.evaluate', { + expression, + returnByValue: returnByValue ?? true, + awaitPromise: true, + }); + + if (result.exceptionDetails) { + const errText = + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + 'Unknown evaluation error'; + response.appendResponseLine('**Evaluation error:**'); + response.appendResponseLine('```'); + response.appendResponseLine(errText); + response.appendResponseLine('```'); + return; + } + + const value = result.result?.value; + response.appendResponseLine('**Result:**'); + response.appendResponseLine('```json'); + response.appendResponseLine( + typeof value === 'string' ? value : JSON.stringify(value, null, 2), + ); + response.appendResponseLine('```'); + }, +}); diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts deleted file mode 100644 index 637664e94..000000000 --- a/src/tools/emulation.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - * - */ - -import {zod, PredefinedNetworkConditions} from '../third_party/index.js'; - -import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; - -const throttlingOptions: [string, ...string[]] = [ - 'No emulation', - 'Offline', - ...Object.keys(PredefinedNetworkConditions), -]; - -export const emulate = defineTool({ - name: 'emulate', - description: `Emulates various features on the selected page.`, - annotations: { - category: ToolCategory.EMULATION, - readOnlyHint: false, - }, - schema: { - networkConditions: zod - .enum(throttlingOptions) - .optional() - .describe( - `Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged.`, - ), - cpuThrottlingRate: zod - .number() - .min(1) - .max(20) - .optional() - .describe( - 'Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.', - ), - geolocation: zod - .object({ - latitude: zod - .number() - .min(-90) - .max(90) - .describe('Latitude between -90 and 90.'), - longitude: zod - .number() - .min(-180) - .max(180) - .describe('Longitude between -180 and 180.'), - }) - .nullable() - .optional() - .describe( - 'Geolocation to emulate. Set to null to clear the geolocation override.', - ), - userAgent: zod - .string() - .nullable() - .optional() - .describe( - 'User agent to emulate. Set to null to clear the user agent override.', - ), - colorScheme: zod - .enum(['dark', 'light', 'auto']) - .optional() - .describe( - 'Emulate the dark or the light mode. Set to "auto" to reset to the default.', - ), - viewport: zod - .object({ - width: zod.number().int().min(0).describe('Page width in pixels.'), - height: zod.number().int().min(0).describe('Page height in pixels.'), - deviceScaleFactor: zod - .number() - .min(0) - .optional() - .describe('Specify device scale factor (can be thought of as dpr).'), - isMobile: zod - .boolean() - .optional() - .describe( - 'Whether the meta viewport tag is taken into account. Defaults to false.', - ), - hasTouch: zod - .boolean() - .optional() - .describe( - 'Specifies if viewport supports touch events. This should be set to true for mobile devices.', - ), - isLandscape: zod - .boolean() - .optional() - .describe( - 'Specifies if viewport is in landscape mode. Defaults to false.', - ), - }) - .nullable() - .optional() - .describe( - 'Viewport to emulate. Set to null to reset to the default viewport.', - ), - }, - handler: async (request, _response, context) => { - const page = context.getSelectedPage(); - const { - networkConditions, - cpuThrottlingRate, - geolocation, - userAgent, - viewport, - } = request.params; - - if (networkConditions) { - if (networkConditions === 'No emulation') { - await page.emulateNetworkConditions(null); - context.setNetworkConditions(null); - } else if (networkConditions === 'Offline') { - await page.emulateNetworkConditions({ - offline: true, - download: 0, - upload: 0, - latency: 0, - }); - context.setNetworkConditions('Offline'); - } else if (networkConditions in PredefinedNetworkConditions) { - const networkCondition = - PredefinedNetworkConditions[ - networkConditions as keyof typeof PredefinedNetworkConditions - ]; - await page.emulateNetworkConditions(networkCondition); - context.setNetworkConditions(networkConditions); - } - } - - if (cpuThrottlingRate) { - await page.emulateCPUThrottling(cpuThrottlingRate); - context.setCpuThrottlingRate(cpuThrottlingRate); - } - - if (geolocation !== undefined) { - if (geolocation === null) { - await page.setGeolocation({latitude: 0, longitude: 0}); - context.setGeolocation(null); - } else { - await page.setGeolocation(geolocation); - context.setGeolocation(geolocation); - } - } - - if (userAgent !== undefined) { - if (userAgent === null) { - await page.setUserAgent({ - userAgent: undefined, - }); - context.setUserAgent(null); - } else { - await page.setUserAgent({ - userAgent, - }); - context.setUserAgent(userAgent); - } - } - - if (request.params.colorScheme) { - if (request.params.colorScheme === 'auto') { - await page.emulateMediaFeatures([ - {name: 'prefers-color-scheme', value: ''}, - ]); - context.setColorScheme(null); - } else { - await page.emulateMediaFeatures([ - { - name: 'prefers-color-scheme', - value: request.params.colorScheme, - }, - ]); - context.setColorScheme(request.params.colorScheme); - } - } - - if (viewport !== undefined) { - if (viewport === null) { - await page.setViewport(null); - context.setViewport(null); - } else { - const defaults = { - deviceScaleFactor: 1, - isMobile: false, - hasTouch: false, - isLandscape: false, - }; - await page.setViewport({...defaults, ...viewport}); - context.setViewport({...defaults, ...viewport}); - } - } - }, -}); diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts deleted file mode 100644 index 0ab2d43ff..000000000 --- a/src/tools/extensions.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {zod} from '../third_party/index.js'; - -import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; - -const EXTENSIONS_CONDITION = 'experimentalExtensionSupport'; - -export const installExtension = defineTool({ - name: 'install_extension', - description: 'Installs a Chrome extension from the given path.', - annotations: { - category: ToolCategory.EXTENSIONS, - readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], - }, - schema: { - path: zod - .string() - .describe('Absolute path to the unpacked extension folder.'), - }, - handler: async (request, response, context) => { - const {path} = request.params; - const id = await context.installExtension(path); - response.appendResponseLine(`Extension installed. Id: ${id}`); - }, -}); - -export const uninstallExtension = defineTool({ - name: 'uninstall_extension', - description: 'Uninstalls a Chrome extension by its ID.', - annotations: { - category: ToolCategory.EXTENSIONS, - readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], - }, - schema: { - id: zod.string().describe('ID of the extension to uninstall.'), - }, - handler: async (request, response, context) => { - const {id} = request.params; - await context.uninstallExtension(id); - response.appendResponseLine(`Extension uninstalled. Id: ${id}`); - }, -}); - -export const listExtensions = defineTool({ - name: 'list_extensions', - description: - 'Lists all extensions via this server, including their name, ID, version, and enabled status.', - annotations: { - category: ToolCategory.EXTENSIONS, - readOnlyHint: true, - conditions: [EXTENSIONS_CONDITION], - }, - schema: {}, - handler: async (_request, response, _context) => { - response.setListExtensions(); - }, -}); - -export const reloadExtension = defineTool({ - name: 'reload_extension', - description: 'Reloads an unpacked Chrome extension by its ID.', - annotations: { - category: ToolCategory.EXTENSIONS, - readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], - }, - schema: { - id: zod.string().describe('ID of the extension to reload.'), - }, - handler: async (request, response, context) => { - const {id} = request.params; - const extension = context.getExtension(id); - if (!extension) { - throw new Error(`Extension with ID ${id} not found.`); - } - await context.installExtension(extension.path); - response.appendResponseLine('Extension reloaded.'); - }, -}); diff --git a/src/tools/pages.ts b/src/tools/pages.ts deleted file mode 100644 index 60fc8f3b6..000000000 --- a/src/tools/pages.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {logger} from '../logger.js'; -import type {Dialog} from '../third_party/index.js'; -import {zod} from '../third_party/index.js'; - -import {ToolCategory} from './categories.js'; -import {CLOSE_PAGE_ERROR, defineTool, timeoutSchema} from './ToolDefinition.js'; - -export const listPages = defineTool({ - name: 'list_pages', - description: `Get a list of pages open in the browser.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: true, - }, - schema: {}, - handler: async (_request, response) => { - response.setIncludePages(true); - }, -}); - -export const selectPage = defineTool({ - name: 'select_page', - description: `Select a page as a context for future tool calls.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: true, - }, - schema: { - pageId: zod - .number() - .describe( - `The ID of the page to select. Call ${listPages.name} to get available pages.`, - ), - bringToFront: zod - .boolean() - .optional() - .describe('Whether to focus the page and bring it to the top.'), - }, - handler: async (request, response, context) => { - const page = context.getPageById(request.params.pageId); - context.selectPage(page); - response.setIncludePages(true); - if (request.params.bringToFront) { - await page.bringToFront(); - } - }, -}); - -export const closePage = defineTool({ - name: 'close_page', - description: `Closes the page by its index. The last open page cannot be closed.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: false, - }, - schema: { - pageId: zod - .number() - .describe('The ID of the page to close. Call list_pages to list pages.'), - }, - handler: async (request, response, context) => { - try { - await context.closePage(request.params.pageId); - } catch (err) { - if (err.message === CLOSE_PAGE_ERROR) { - response.appendResponseLine(err.message); - } else { - throw err; - } - } - response.setIncludePages(true); - }, -}); - -export const newPage = defineTool({ - name: 'new_page', - description: `Creates a new page`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: false, - }, - schema: { - url: zod.string().describe('URL to load in a new page.'), - background: zod - .boolean() - .optional() - .describe( - 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).', - ), - ...timeoutSchema, - }, - handler: async (request, response, context) => { - const page = await context.newPage(request.params.background); - - await context.waitForEventsAfterAction( - async () => { - await page.goto(request.params.url, { - timeout: request.params.timeout, - }); - }, - {timeout: request.params.timeout}, - ); - - response.setIncludePages(true); - }, -}); - -export const navigatePage = defineTool({ - name: 'navigate_page', - description: `Navigates the currently selected page to a URL.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: false, - }, - schema: { - type: zod - .enum(['url', 'back', 'forward', 'reload']) - .optional() - .describe( - 'Navigate the page by URL, back or forward in history, or reload.', - ), - url: zod.string().optional().describe('Target URL (only type=url)'), - ignoreCache: zod - .boolean() - .optional() - .describe('Whether to ignore cache on reload.'), - handleBeforeUnload: zod - .enum(['accept', 'decline']) - .optional() - .describe( - 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.', - ), - initScript: zod - .string() - .optional() - .describe( - 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.', - ), - ...timeoutSchema, - }, - handler: async (request, response, context) => { - const page = context.getSelectedPage(); - const options = { - timeout: request.params.timeout, - }; - - if (!request.params.type && !request.params.url) { - throw new Error('Either URL or a type is required.'); - } - - if (!request.params.type) { - request.params.type = 'url'; - } - - const handleBeforeUnload = request.params.handleBeforeUnload ?? 'accept'; - const dialogHandler = (dialog: Dialog) => { - if (dialog.type() === 'beforeunload') { - if (handleBeforeUnload === 'accept') { - response.appendResponseLine(`Accepted a beforeunload dialog.`); - void dialog.accept(); - } else { - response.appendResponseLine(`Declined a beforeunload dialog.`); - void dialog.dismiss(); - } - // We are not going to report the dialog like regular dialogs. - context.clearDialog(); - } - }; - - let initScriptId: string | undefined; - if (request.params.initScript) { - const {identifier} = await page.evaluateOnNewDocument( - request.params.initScript, - ); - initScriptId = identifier; - } - - page.on('dialog', dialogHandler); - - try { - await context.waitForEventsAfterAction( - async () => { - switch (request.params.type) { - case 'url': - if (!request.params.url) { - throw new Error( - 'A URL is required for navigation of type=url.', - ); - } - try { - await page.goto(request.params.url, options); - response.appendResponseLine( - `Successfully navigated to ${request.params.url}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate in the selected page: ${error.message}.`, - ); - } - break; - case 'back': - try { - await page.goBack(options); - response.appendResponseLine( - `Successfully navigated back to ${page.url()}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate back in the selected page: ${error.message}.`, - ); - } - break; - case 'forward': - try { - await page.goForward(options); - response.appendResponseLine( - `Successfully navigated forward to ${page.url()}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate forward in the selected page: ${error.message}.`, - ); - } - break; - case 'reload': - try { - await page.reload({ - ...options, - ignoreCache: request.params.ignoreCache, - }); - response.appendResponseLine(`Successfully reloaded the page.`); - } catch (error) { - response.appendResponseLine( - `Unable to reload the selected page: ${error.message}.`, - ); - } - break; - } - }, - {timeout: request.params.timeout}, - ); - } finally { - page.off('dialog', dialogHandler); - if (initScriptId) { - await page - .removeScriptToEvaluateOnNewDocument(initScriptId) - .catch(error => { - logger(`Failed to remove init script`, error); - }); - } - } - - response.setIncludePages(true); - }, -}); - -export const resizePage = defineTool({ - name: 'resize_page', - description: `Resizes the selected page's window so that the page has specified dimension`, - annotations: { - category: ToolCategory.EMULATION, - readOnlyHint: false, - }, - schema: { - width: zod.number().describe('Page width'), - height: zod.number().describe('Page height'), - }, - handler: async (request, response, context) => { - const page = context.getSelectedPage(); - - try { - const browser = page.browser(); - const windowId = await page.windowId(); - - const bounds = await browser.getWindowBounds(windowId); - - if (bounds.windowState === 'fullscreen') { - // Have to call this twice on Ubuntu when the window is in fullscreen mode. - await browser.setWindowBounds(windowId, {windowState: 'normal'}); - await browser.setWindowBounds(windowId, {windowState: 'normal'}); - } else if (bounds.windowState !== 'normal') { - await browser.setWindowBounds(windowId, {windowState: 'normal'}); - } - } catch { - // Window APIs are not supported on all platforms - } - await page.resize({ - contentWidth: request.params.width, - contentHeight: request.params.height, - }); - - response.setIncludePages(true); - }, -}); - -export const handleDialog = defineTool({ - name: 'handle_dialog', - description: `If a browser dialog was opened, use this command to handle it`, - annotations: { - category: ToolCategory.INPUT, - readOnlyHint: false, - }, - schema: { - action: zod - .enum(['accept', 'dismiss']) - .describe('Whether to dismiss or accept the dialog'), - promptText: zod - .string() - .optional() - .describe('Optional prompt text to enter into the dialog.'), - }, - handler: async (request, response, context) => { - const dialog = context.getDialog(); - if (!dialog) { - throw new Error('No open dialog found'); - } - - switch (request.params.action) { - case 'accept': { - try { - await dialog.accept(request.params.promptText); - } catch (err) { - // Likely already handled by the user outside of MCP. - logger(err); - } - response.appendResponseLine('Successfully accepted the dialog'); - break; - } - case 'dismiss': { - try { - await dialog.dismiss(); - } catch (err) { - // Likely already handled. - logger(err); - } - response.appendResponseLine('Successfully dismissed the dialog'); - break; - } - } - - context.clearDialog(); - response.setIncludePages(true); - }, -}); - -export const getTabId = defineTool({ - name: 'get_tab_id', - description: `Get the tab ID of the page`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: true, - conditions: ['experimentalInteropTools'], - }, - schema: { - pageId: zod - .number() - .describe( - `The ID of the page to get the tab ID for. Call ${listPages.name} to get available pages.`, - ), - }, - handler: async (request, response, context) => { - const page = context.getPageById(request.params.pageId); - // @ts-expect-error _tabId is internal. - const tabId = page._tabId; - response.setTabId(tabId); - }, -}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0b9dc53ce..5dba1b564 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -5,11 +5,10 @@ */ import * as consoleTools from './console.js'; -import * as emulationTools from './emulation.js'; -import * as extensionTools from './extensions.js'; +import * as debugBridgeExecTools from './debug-bridge-exec.js'; +import * as debugEvaluateTools from './debug-evaluate.js'; import * as inputTools from './input.js'; import * as networkTools from './network.js'; -import * as pagesTools from './pages.js'; import * as performanceTools from './performance.js'; import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; @@ -18,11 +17,10 @@ import type {ToolDefinition} from './ToolDefinition.js'; const tools = [ ...Object.values(consoleTools), - ...Object.values(emulationTools), - ...Object.values(extensionTools), + ...Object.values(debugBridgeExecTools), + ...Object.values(debugEvaluateTools), ...Object.values(inputTools), ...Object.values(networkTools), - ...Object.values(pagesTools), ...Object.values(performanceTools), ...Object.values(screenshotTools), ...Object.values(scriptTools), diff --git a/tests/browser.test.ts b/tests/browser.test.ts deleted file mode 100644 index aad6eec9c..000000000 --- a/tests/browser.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import os from 'node:os'; -import path from 'node:path'; -import {describe, it} from 'node:test'; - -import {executablePath} from 'puppeteer'; - -import {ensureBrowserConnected, launch} from '../src/browser.js'; - -describe('browser', () => { - it('cannot launch multiple times with the same profile', async () => { - const tmpDir = os.tmpdir(); - const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); - const browser1 = await launch({ - headless: true, - isolated: false, - userDataDir: folderPath, - executablePath: executablePath(), - devtools: false, - }); - try { - try { - const browser2 = await launch({ - headless: true, - isolated: false, - userDataDir: folderPath, - executablePath: executablePath(), - devtools: false, - }); - await browser2.close(); - assert.fail('not reached'); - } catch (err) { - assert.strictEqual( - err.message, - `The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`, - ); - } - } finally { - await browser1.close(); - } - }); - - it('launches with the initial viewport', async () => { - const tmpDir = os.tmpdir(); - const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); - const browser = await launch({ - headless: true, - isolated: false, - userDataDir: folderPath, - executablePath: executablePath(), - viewport: { - width: 1501, - height: 801, - }, - devtools: false, - }); - try { - const [page] = await browser.pages(); - const result = await page.evaluate(() => { - return {width: window.innerWidth, height: window.innerHeight}; - }); - assert.deepStrictEqual(result, { - width: 1501, - height: 801, - }); - } finally { - await browser.close(); - } - }); - it('connects to an existing browser with userDataDir', async () => { - const tmpDir = os.tmpdir(); - const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); - const browser = await launch({ - headless: true, - isolated: false, - userDataDir: folderPath, - executablePath: executablePath(), - devtools: false, - chromeArgs: ['--remote-debugging-port=0'], - }); - try { - const connectedBrowser = await ensureBrowserConnected({ - userDataDir: folderPath, - devtools: false, - }); - assert.ok(connectedBrowser); - assert.ok(connectedBrowser.connected); - connectedBrowser.disconnect(); - } finally { - await browser.close(); - } - }); -}); diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts deleted file mode 100644 index f66ea221e..000000000 --- a/tests/tools/emulation.test.ts +++ /dev/null @@ -1,575 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {beforeEach, describe, it} from 'node:test'; - -import {emulate} from '../../src/tools/emulation.js'; -import {serverHooks} from '../server.js'; -import {html, withMcpContext} from '../utils.js'; - -describe('emulation', () => { - const server = serverHooks(); - - describe('network', () => { - it('emulates offline network conditions', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - networkConditions: 'Offline', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getNetworkConditions(), 'Offline'); - }); - }); - it('emulates network throttling when the throttling option is valid', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - networkConditions: 'Slow 3G', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getNetworkConditions(), 'Slow 3G'); - }); - }); - - it('disables network emulation', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - networkConditions: 'No emulation', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getNetworkConditions(), null); - }); - }); - - it('does not set throttling when the network throttling is not one of the predefined options', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - networkConditions: 'Slow 11G', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getNetworkConditions(), null); - }); - }); - - it('report correctly for the currently selected page', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - networkConditions: 'Slow 3G', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getNetworkConditions(), 'Slow 3G'); - - const page = await context.newPage(); - context.selectPage(page); - - assert.strictEqual(context.getNetworkConditions(), null); - }); - }); - }); - - describe('cpu', () => { - it('emulates cpu throttling when the rate is valid (1-20x)', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - cpuThrottlingRate: 4, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getCpuThrottlingRate(), 4); - }); - }); - - it('disables cpu throttling', async () => { - await withMcpContext(async (response, context) => { - context.setCpuThrottlingRate(4); // Set it to something first. - await emulate.handler( - { - params: { - cpuThrottlingRate: 1, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getCpuThrottlingRate(), 1); - }); - }); - - it('report correctly for the currently selected page', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - cpuThrottlingRate: 4, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getCpuThrottlingRate(), 4); - - const page = await context.newPage(); - context.selectPage(page); - - assert.strictEqual(context.getCpuThrottlingRate(), 1); - }); - }); - }); - - describe('geolocation', () => { - it('emulates geolocation with latitude and longitude', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - geolocation: { - latitude: 48.137154, - longitude: 11.576124, - }, - }, - }, - response, - context, - ); - - const geolocation = context.getGeolocation(); - assert.strictEqual(geolocation?.latitude, 48.137154); - assert.strictEqual(geolocation?.longitude, 11.576124); - }); - }); - - it('clears geolocation override when geolocation is set to null', async () => { - await withMcpContext(async (response, context) => { - // First set a geolocation - await emulate.handler( - { - params: { - geolocation: { - latitude: 48.137154, - longitude: 11.576124, - }, - }, - }, - response, - context, - ); - - assert.notStrictEqual(context.getGeolocation(), null); - - // Then clear it by setting geolocation to null - await emulate.handler( - { - params: { - geolocation: null, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getGeolocation(), null); - }); - }); - - it('reports correctly for the currently selected page', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - geolocation: { - latitude: 48.137154, - longitude: 11.576124, - }, - }, - }, - response, - context, - ); - - const geolocation = context.getGeolocation(); - assert.strictEqual(geolocation?.latitude, 48.137154); - assert.strictEqual(geolocation?.longitude, 11.576124); - - const page = await context.newPage(); - context.selectPage(page); - - assert.strictEqual(context.getGeolocation(), null); - }); - }); - }); - describe('viewport', () => { - beforeEach(() => { - server.addHtmlRoute('/viewport', html`Test page`); - }); - - it('emulates viewport', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.goto(server.baseUrl + '/viewport'); - await emulate.handler( - { - params: { - viewport: { - width: 400, - height: 400, - deviceScaleFactor: 2, - isMobile: true, - hasTouch: true, - isLandscape: false, - }, - }, - }, - response, - context, - ); - - const viewportData = await page.evaluate(() => { - return { - width: window.innerWidth, - height: window.innerHeight, - deviceScaleFactor: window.devicePixelRatio, - hasTouch: navigator.maxTouchPoints > 0, - }; - }); - - assert.deepStrictEqual(viewportData, { - width: 400, - height: 400, - deviceScaleFactor: 2, - hasTouch: true, - }); - }); - }); - - it('clears viewport override when viewport is set to null', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - // First set a viewport - await emulate.handler( - { - params: { - viewport: { - width: 400, - height: 400, - }, - }, - }, - response, - context, - ); - - const viewportData = await page.evaluate(() => { - return { - width: window.innerWidth, - height: window.innerHeight, - }; - }); - - assert.deepStrictEqual(viewportData, { - width: 400, - height: 400, - }); - - // Then clear it by setting viewport to null - await emulate.handler( - { - params: { - viewport: null, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getViewport(), null); - - // Somehow reset of the viewport seems to be async. - await context.getSelectedPage().waitForFunction(() => { - return window.innerWidth !== 400 && window.innerHeight !== 400; - }); - }); - }); - - it('reports correctly for the currently selected page', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - viewport: { - width: 400, - height: 400, - }, - }, - }, - response, - context, - ); - - assert.ok(context.getViewport()); - - const page = await context.newPage(); - context.selectPage(page); - - assert.strictEqual(context.getViewport(), null); - assert.ok( - await context.getSelectedPage().evaluate(() => { - return window.innerWidth !== 400 && window.innerHeight !== 400; - }), - ); - }); - }); - }); - - describe('userAgent', () => { - it('emulates userAgent', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - userAgent: 'MyUA', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getUserAgent(), 'MyUA'); - const page = context.getSelectedPage(); - const ua = await page.evaluate(() => navigator.userAgent); - assert.strictEqual(ua, 'MyUA'); - }); - }); - - it('updates userAgent', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - userAgent: 'UA1', - }, - }, - response, - context, - ); - assert.strictEqual(context.getUserAgent(), 'UA1'); - - await emulate.handler( - { - params: { - userAgent: 'UA2', - }, - }, - response, - context, - ); - assert.strictEqual(context.getUserAgent(), 'UA2'); - const page = context.getSelectedPage(); - const ua = await page.evaluate(() => navigator.userAgent); - assert.strictEqual(ua, 'UA2'); - }); - }); - - it('clears userAgent override when userAgent is set to null', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - userAgent: 'MyUA', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getUserAgent(), 'MyUA'); - - await emulate.handler( - { - params: { - userAgent: null, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getUserAgent(), null); - const page = context.getSelectedPage(); - const ua = await page.evaluate(() => navigator.userAgent); - assert.notStrictEqual(ua, 'MyUA'); - assert.ok(ua.length > 0); - }); - }); - - it('reports correctly for the currently selected page', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - userAgent: 'MyUA', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getUserAgent(), 'MyUA'); - - const page = await context.newPage(); - context.selectPage(page); - - assert.strictEqual(context.getUserAgent(), null); - assert.ok( - await context.getSelectedPage().evaluate(() => { - return navigator.userAgent !== 'MyUA'; - }), - ); - }); - }); - }); - - describe('colorScheme', () => { - it('emulates color scheme', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - colorScheme: 'dark', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getColorScheme(), 'dark'); - const page = context.getSelectedPage(); - const scheme = await page.evaluate(() => - window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light', - ); - assert.strictEqual(scheme, 'dark'); - }); - }); - - it('updates color scheme', async () => { - await withMcpContext(async (response, context) => { - await emulate.handler( - { - params: { - colorScheme: 'dark', - }, - }, - response, - context, - ); - assert.strictEqual(context.getColorScheme(), 'dark'); - - await emulate.handler( - { - params: { - colorScheme: 'light', - }, - }, - response, - context, - ); - assert.strictEqual(context.getColorScheme(), 'light'); - const page = context.getSelectedPage(); - const scheme = await page.evaluate(() => - window.matchMedia('(prefers-color-scheme: light)').matches - ? 'light' - : 'dark', - ); - assert.strictEqual(scheme, 'light'); - }); - }); - - it('resets color scheme when set to auto', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - - const initial = await page.evaluate( - () => window.matchMedia('(prefers-color-scheme: dark)').matches, - ); - - await emulate.handler( - { - params: { - colorScheme: 'dark', - }, - }, - response, - context, - ); - assert.strictEqual(context.getColorScheme(), 'dark'); - // Check manually that it is dark - - assert.strictEqual( - await page.evaluate( - () => window.matchMedia('(prefers-color-scheme: dark)').matches, - ), - true, - ); - - await emulate.handler( - { - params: { - colorScheme: 'auto', - }, - }, - response, - context, - ); - - assert.strictEqual(context.getColorScheme(), null); - assert.strictEqual( - await page.evaluate( - () => window.matchMedia('(prefers-color-scheme: dark)').matches, - ), - initial, - ); - }); - }); - }); -}); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts deleted file mode 100644 index 65f645ab4..000000000 --- a/tests/tools/extensions.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import path from 'node:path'; -import {describe, it} from 'node:test'; - -import sinon from 'sinon'; - -import type {McpResponse} from '../../src/McpResponse.js'; -import { - installExtension, - uninstallExtension, - listExtensions, - reloadExtension, -} from '../../src/tools/extensions.js'; -import {withMcpContext} from '../utils.js'; - -const EXTENSION_PATH = path.join( - import.meta.dirname, - '../../../tests/tools/fixtures/extension', -); - -function extractId(response: McpResponse) { - const responseLine = response.responseLines[0]; - assert.ok(responseLine, 'Response should not be empty'); - const match = responseLine.match(/Extension installed\. Id: (.+)/); - const extensionId = match ? match[1] : null; - assert.ok(extensionId, 'Response should contain a valid key'); - return extensionId; -} - -describe('extension', () => { - it('installs and uninstalls an extension and verifies it in chrome://extensions', async () => { - await withMcpContext(async (response, context) => { - // Install the extension - await installExtension.handler( - {params: {path: EXTENSION_PATH}}, - response, - context, - ); - - const extensionId = extractId(response); - const page = context.getSelectedPage(); - await page.goto('chrome://extensions'); - - const element = await page.waitForSelector( - `extensions-manager >>> extensions-item[id="${extensionId}"]`, - ); - assert.ok( - element, - `Extension with ID "${extensionId}" should be visible on chrome://extensions`, - ); - - // Uninstall the extension - await uninstallExtension.handler( - {params: {id: extensionId!}}, - response, - context, - ); - - const uninstallResponseLine = response.responseLines[1]; - assert.ok( - uninstallResponseLine.includes('Extension uninstalled'), - 'Response should indicate uninstallation', - ); - - await page.waitForSelector('extensions-manager'); - - const elementAfterUninstall = await page.$( - `extensions-manager >>> extensions-item[id="${extensionId}"]`, - ); - assert.strictEqual( - elementAfterUninstall, - null, - `Extension with ID "${extensionId}" should NOT be visible on chrome://extensions`, - ); - }); - }); - it('lists installed extensions', async () => { - await withMcpContext(async (response, context) => { - const setListExtensionsSpy = sinon.spy(response, 'setListExtensions'); - await listExtensions.handler({params: {}}, response, context); - assert.ok( - setListExtensionsSpy.calledOnce, - 'setListExtensions should be called', - ); - }); - }); - it('reloads an extension', async () => { - await withMcpContext(async (response, context) => { - await installExtension.handler( - {params: {path: EXTENSION_PATH}}, - response, - context, - ); - - const extensionId = extractId(response); - const installSpy = sinon.spy(context, 'installExtension'); - response.resetResponseLineForTesting(); - - await reloadExtension.handler( - {params: {id: extensionId!}}, - response, - context, - ); - assert.ok( - installSpy.calledOnceWithExactly(EXTENSION_PATH), - 'installExtension should be called with the extension path', - ); - - const reloadResponseLine = response.responseLines[0]; - assert.ok( - reloadResponseLine.includes('Extension reloaded'), - 'Response should indicate reload', - ); - - const list = context.listExtensions(); - assert.ok(list.length === 1, 'List should have only one extension'); - const reinstalled = list.find(e => e.id === extensionId); - assert.ok(reinstalled, 'Extension should be present after reload'); - }); - }); -}); diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts deleted file mode 100644 index 2d4110f8b..000000000 --- a/tests/tools/pages.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import type {Dialog} from 'puppeteer-core'; -import sinon from 'sinon'; - -import { - listPages, - newPage, - closePage, - selectPage, - navigatePage, - resizePage, - handleDialog, - getTabId, -} from '../../src/tools/pages.js'; -import {html, withMcpContext} from '../utils.js'; - -describe('pages', () => { - describe('list_pages', () => { - it('list pages', async () => { - await withMcpContext(async (response, context) => { - await listPages.handler({params: {}}, response, context); - assert.ok(response.includePages); - }); - }); - }); - describe('new_page', () => { - it('create a page', async () => { - await withMcpContext(async (response, context) => { - assert.strictEqual(context.getPageById(1), context.getSelectedPage()); - await newPage.handler( - {params: {url: 'about:blank'}}, - response, - context, - ); - assert.strictEqual(context.getPageById(2), context.getSelectedPage()); - assert.ok(response.includePages); - }); - }); - it('create a page in the background', async () => { - await withMcpContext(async (response, context) => { - const originalPage = context.getPageById(1); - assert.strictEqual(originalPage, context.getSelectedPage()); - // Ensure original page has focus - await originalPage.bringToFront(); - assert.strictEqual( - await originalPage.evaluate(() => document.hasFocus()), - true, - ); - await newPage.handler( - {params: {url: 'about:blank', background: true}}, - response, - context, - ); - // New page should be selected but original should retain focus - assert.strictEqual(context.getPageById(2), context.getSelectedPage()); - assert.strictEqual( - await originalPage.evaluate(() => document.hasFocus()), - true, - ); - assert.ok(response.includePages); - }); - }); - }); - describe('close_page', () => { - it('closes a page', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - assert.strictEqual(context.getPageById(2), context.getSelectedPage()); - assert.strictEqual(context.getPageById(2), page); - await closePage.handler({params: {pageId: 2}}, response, context); - assert.ok(page.isClosed()); - assert.ok(response.includePages); - }); - }); - it('cannot close the last page', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await closePage.handler({params: {pageId: 1}}, response, context); - assert.deepStrictEqual( - response.responseLines[0], - `The last open page cannot be closed. It is fine to keep it open.`, - ); - assert.ok(response.includePages); - assert.ok(!page.isClosed()); - }); - }); - }); - describe('select_page', () => { - it('selects a page', async () => { - await withMcpContext(async (response, context) => { - await context.newPage(); - assert.strictEqual(context.getPageById(2), context.getSelectedPage()); - await selectPage.handler({params: {pageId: 1}}, response, context); - assert.strictEqual(context.getPageById(1), context.getSelectedPage()); - assert.ok(response.includePages); - }); - }); - it('selects a page and keeps it focused in the background', async () => { - await withMcpContext(async (response, context) => { - await context.newPage(); - assert.strictEqual(context.getPageById(2), context.getSelectedPage()); - assert.strictEqual( - await context.getPageById(1).evaluate(() => document.hasFocus()), - false, - ); - await selectPage.handler({params: {pageId: 1}}, response, context); - assert.strictEqual(context.getPageById(1), context.getSelectedPage()); - assert.strictEqual( - await context.getPageById(1).evaluate(() => document.hasFocus()), - true, - ); - assert.ok(response.includePages); - }); - }); - }); - describe('navigate_page', () => { - it('navigates to correct page', async () => { - await withMcpContext(async (response, context) => { - await navigatePage.handler( - {params: {url: 'data:text/html,
Hello MCP
'}}, - response, - context, - ); - const page = context.getSelectedPage(); - assert.equal( - await page.evaluate(() => document.querySelector('div')?.textContent), - 'Hello MCP', - ); - assert.ok(response.includePages); - }); - }); - - it('throws an error if the page was closed not by the MCP server', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - assert.strictEqual(context.getPageById(2), context.getSelectedPage()); - assert.strictEqual(context.getPageById(2), page); - - await page.close(); - - try { - await navigatePage.handler( - {params: {url: 'data:text/html,
Hello MCP
'}}, - response, - context, - ); - assert.fail('should not reach here'); - } catch (err) { - assert.strictEqual( - err.message, - 'The selected page has been closed. Call list_pages to see open pages.', - ); - } - }); - }); - - it('respects the timeout parameter', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const stub = sinon.stub(page, 'waitForNavigation').resolves(null); - - try { - await navigatePage.handler( - { - params: { - url: 'about:blank', - timeout: 12345, - }, - }, - response, - context, - ); - } finally { - stub.restore(); - } - - assert.strictEqual( - stub.firstCall.args[0]?.timeout, - 12345, - 'The timeout parameter should be passed to waitForNavigation', - ); - }); - }); - it('go back', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.goto('data:text/html,
Hello MCP
'); - await navigatePage.handler({params: {type: 'back'}}, response, context); - - assert.equal( - await page.evaluate(() => document.location.href), - 'about:blank', - ); - assert.ok(response.includePages); - }); - }); - it('go forward', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.goto('data:text/html,
Hello MCP
'); - await page.goBack(); - await navigatePage.handler( - {params: {type: 'forward'}}, - response, - context, - ); - - assert.equal( - await page.evaluate(() => document.querySelector('div')?.textContent), - 'Hello MCP', - ); - assert.ok(response.includePages); - }); - }); - it('reload', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.goto('data:text/html,
Hello MCP
'); - await navigatePage.handler( - {params: {type: 'reload'}}, - response, - context, - ); - - assert.equal( - await page.evaluate(() => document.location.href), - 'data:text/html,
Hello MCP
', - ); - assert.ok(response.includePages); - }); - }); - - it('reload with accpeting the beforeunload dialog', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html` `, - ); - - await navigatePage.handler( - {params: {type: 'reload'}}, - response, - context, - ); - - assert.strictEqual(context.getDialog(), undefined); - assert.ok(response.includePages); - assert.strictEqual( - response.responseLines.join('\n'), - 'Accepted a beforeunload dialog.\nSuccessfully reloaded the page.', - ); - }); - }); - - it('reload with declining the beforeunload dialog', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html` `, - ); - - await navigatePage.handler( - { - params: { - type: 'reload', - handleBeforeUnload: 'decline', - timeout: 500, - }, - }, - response, - context, - ); - - assert.strictEqual(context.getDialog(), undefined); - assert.ok(response.includePages); - assert.strictEqual( - response.responseLines.join('\n'), - 'Declined a beforeunload dialog.\nUnable to reload the selected page: Navigation timeout of 500 ms exceeded.', - ); - }); - }); - - it('go forward with error', async () => { - await withMcpContext(async (response, context) => { - await navigatePage.handler( - {params: {type: 'forward'}}, - response, - context, - ); - - assert.ok( - response.responseLines - .at(0) - ?.startsWith('Unable to navigate forward in the selected page:'), - ); - assert.ok(response.includePages); - }); - }); - it('go back with error', async () => { - await withMcpContext(async (response, context) => { - await navigatePage.handler({params: {type: 'back'}}, response, context); - - assert.ok( - response.responseLines - .at(0) - ?.startsWith('Unable to navigate back in the selected page:'), - ); - assert.ok(response.includePages); - }); - }); - it('navigates to correct page with initScript', async () => { - await withMcpContext(async (response, context) => { - await navigatePage.handler( - { - params: { - url: 'data:text/html,
Hello MCP
', - initScript: 'window.initScript = "completed"', - }, - }, - response, - context, - ); - const page = context.getSelectedPage(); - - // wait for up to 1s for the global variable to set by the initScript to exist - await page.waitForFunction("window.initScript==='completed'", { - timeout: 1000, - }); - - assert.ok(response.includePages); - }); - }); - }); - describe('resize', () => { - it('resize the page', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const resizePromise = page.evaluate(() => { - return new Promise(resolve => { - window.addEventListener('resize', resolve, {once: true}); - }); - }); - await resizePage.handler( - {params: {width: 700, height: 500}}, - response, - context, - ); - await resizePromise; - await page.waitForFunction( - () => window.innerWidth === 700 && window.innerHeight === 500, - ); - const dimensions = await page.evaluate(() => { - return [window.innerWidth, window.innerHeight]; - }); - assert.deepStrictEqual(dimensions, [700, 500]); - }); - }); - - it('resize when window state is normal', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const browser = page.browser(); - const windowId = await page.windowId(); - await browser.setWindowBounds(windowId, {windowState: 'normal'}); - - const {windowState} = await browser.getWindowBounds(windowId); - assert.strictEqual(windowState, 'normal'); - - const resizePromise = page.evaluate(() => { - return new Promise(resolve => { - window.addEventListener('resize', resolve, {once: true}); - }); - }); - await resizePage.handler( - {params: {width: 650, height: 450}}, - response, - context, - ); - await resizePromise; - await page.waitForFunction( - () => window.innerWidth === 650 && window.innerHeight === 450, - ); - const dimensions = await page.evaluate(() => { - return [window.innerWidth, window.innerHeight]; - }); - assert.deepStrictEqual(dimensions, [650, 450]); - }); - }); - - it('resize when window state is minimized', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const browser = page.browser(); - const windowId = await page.windowId(); - await browser.setWindowBounds(windowId, {windowState: 'minimized'}); - - const {windowState} = await browser.getWindowBounds(windowId); - assert.strictEqual(windowState, 'minimized'); - - const resizePromise = page.evaluate(() => { - return new Promise(resolve => { - window.addEventListener('resize', resolve, {once: true}); - }); - }); - await resizePage.handler( - {params: {width: 750, height: 550}}, - response, - context, - ); - await resizePromise; - await page.waitForFunction( - () => window.innerWidth === 750 && window.innerHeight === 550, - ); - const dimensions = await page.evaluate(() => { - return [window.innerWidth, window.innerHeight]; - }); - assert.deepStrictEqual(dimensions, [750, 550]); - }); - }); - - it('resize when window state is maximized', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const browser = page.browser(); - const windowId = await page.windowId(); - await browser.setWindowBounds(windowId, {windowState: 'maximized'}); - - const {windowState} = await browser.getWindowBounds(windowId); - assert.strictEqual(windowState, 'maximized'); - - const resizePromise = page.evaluate(() => { - return new Promise(resolve => { - window.addEventListener('resize', resolve, {once: true}); - }); - }); - await resizePage.handler( - {params: {width: 725, height: 525}}, - response, - context, - ); - await resizePromise; - await page.waitForFunction( - () => window.innerWidth === 725 && window.innerHeight === 525, - ); - const dimensions = await page.evaluate(() => { - return [window.innerWidth, window.innerHeight]; - }); - assert.deepStrictEqual(dimensions, [725, 525]); - }); - }); - - it('resize when window state is fullscreen', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const browser = page.browser(); - const windowId = await page.windowId(); - await browser.setWindowBounds(windowId, {windowState: 'fullscreen'}); - - const {windowState} = await browser.getWindowBounds(windowId); - assert.strictEqual(windowState, 'fullscreen'); - - const resizePromise = page.evaluate(() => { - return new Promise(resolve => { - window.addEventListener('resize', resolve, {once: true}); - }); - }); - await resizePage.handler( - {params: {width: 850, height: 650}}, - response, - context, - ); - await resizePromise; - await page.waitForFunction( - () => window.innerWidth === 850 && window.innerHeight === 650, - ); - const dimensions = await page.evaluate(() => { - return [window.innerWidth, window.innerHeight]; - }); - assert.deepStrictEqual(dimensions, [850, 650]); - }); - }); - }); - - describe('dialogs', () => { - it('can accept dialogs', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const dialogPromise = new Promise(resolve => { - page.on('dialog', () => { - resolve(); - }); - }); - page.evaluate(() => { - alert('test'); - }); - await dialogPromise; - await handleDialog.handler( - { - params: { - action: 'accept', - }, - }, - response, - context, - ); - assert.strictEqual(context.getDialog(), undefined); - assert.strictEqual( - response.responseLines[0], - 'Successfully accepted the dialog', - ); - }); - }); - it('can dismiss dialogs', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const dialogPromise = new Promise(resolve => { - page.on('dialog', () => { - resolve(); - }); - }); - page.evaluate(() => { - alert('test'); - }); - await dialogPromise; - await handleDialog.handler( - { - params: { - action: 'dismiss', - }, - }, - response, - context, - ); - assert.strictEqual(context.getDialog(), undefined); - assert.strictEqual( - response.responseLines[0], - 'Successfully dismissed the dialog', - ); - }); - }); - it('can dismiss already dismissed dialog dialogs', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const dialogPromise = new Promise(resolve => { - page.on('dialog', dialog => { - resolve(dialog); - }); - }); - page.evaluate(() => { - alert('test'); - }); - const dialog = await dialogPromise; - await dialog.dismiss(); - await handleDialog.handler( - { - params: { - action: 'dismiss', - }, - }, - response, - context, - ); - assert.strictEqual(context.getDialog(), undefined); - assert.strictEqual( - response.responseLines[0], - 'Successfully dismissed the dialog', - ); - }); - }); - }); - - describe('get_tab_id', () => { - it('returns the tab id', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - // @ts-expect-error _tabId is internal. - assert.ok(typeof page._tabId === 'string'); - // @ts-expect-error _tabId is internal. - page._tabId = 'test-tab-id'; - await getTabId.handler({params: {pageId: 1}}, response, context); - const result = await response.handle('get_tab_id', context); - // @ts-expect-error _tabId is internal. - assert.strictEqual(result.structuredContent.tabId, 'test-tab-id'); - assert.deepStrictEqual(response.responseLines, []); - }); - }); - }); -}); From 088fd67958699905c71b4faac3895f501704e292 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 02:13:20 -0600 Subject: [PATCH 002/127] feat: implement dev supervisor script and integrate Puppeteer transport for Electron --- scripts/dev-supervisor.mjs | 136 +++++++++++++++++++++++++ src/browser.ts | 198 +++++++++++++++++++++++++++++++++++++ src/cli.ts | 7 ++ src/main.ts | 11 ++- 4 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 scripts/dev-supervisor.mjs diff --git a/scripts/dev-supervisor.mjs b/scripts/dev-supervisor.mjs new file mode 100644 index 000000000..1a8f906c9 --- /dev/null +++ b/scripts/dev-supervisor.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * Dev Supervisor for VS Code DevTools MCP Server + * + * This script: + * 1. Runs tsc to compile the TypeScript source + * 2. Spawns the compiled MCP server as a child process + * 3. Proxies stdio between VS Code and the child + * 4. Watches src/ for .ts file changes + * 5. On change: kills child, recompiles, respawns + * + * VS Code never loses the stdio connection — this supervisor owns the pipe. + */ + +import {spawn, execSync} from 'node:child_process'; +import {watch} from 'node:fs'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const projectRoot = dirname(__dirname); // vscode-devtools-mcp/ +const workspaceRoot = dirname(projectRoot); // workspace root with compile script +const srcDir = join(projectRoot, 'src'); +const buildEntry = join(projectRoot, 'build', 'src', 'index.js'); + +// Pass through all args to the child +const childArgs = [buildEntry, ...process.argv.slice(2)]; + +const log = msg => process.stderr.write(`[dev-supervisor] ${msg}\n`); + +let child = null; +let restarting = false; +let debounceTimer = null; +let compiling = false; + +function compile() { + if (compiling) return false; + compiling = true; + try { + log('Compiling TypeScript...'); + execSync('pnpm run compile', { + cwd: workspaceRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + log('Compilation complete'); + compiling = false; + return true; + } catch (err) { + log(`Compilation failed: ${err.message}`); + if (err.stderr) log(err.stderr.toString()); + compiling = false; + return false; + } +} + +function spawnChild() { + const proc = spawn(process.execPath, childArgs, { + stdio: ['pipe', 'pipe', 'inherit'], + }); + child = proc; + + // Forward stdin → child (MCP requests from VS Code) + const onStdinData = chunk => { + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.write(chunk); + } + }; + process.stdin.on('data', onStdinData); + + // Forward child stdout → stdout (MCP responses to VS Code) + proc.stdout?.on('data', chunk => { + process.stdout.write(chunk); + }); + + proc.on('exit', (code, signal) => { + process.stdin.removeListener('data', onStdinData); + if (restarting) { + // Expected kill during restart — compile and respawn + restarting = false; + if (compile()) { + log('Respawning MCP server...'); + spawnChild(); + } else { + log('Waiting for next file change to retry...'); + } + } else { + // Unexpected exit — propagate + log(`Child exited: code=${code}, signal=${signal}`); + process.exit(code ?? 1); + } + }); + + log(`MCP server started (PID: ${proc.pid})`); +} + +function restartChild() { + if (!child) return; + restarting = true; + log('Killing MCP server for restart...'); + child.kill('SIGTERM'); + // If SIGTERM doesn't work after 2s, force kill (Windows compatibility) + setTimeout(() => { + if (restarting && child && !child.killed) { + log('Force killing with SIGKILL...'); + child.kill('SIGKILL'); + } + }, 2000); +} + +// Watch source directory for .ts changes +watch(srcDir, {recursive: true}, (_event, filename) => { + if (!filename?.toString().endsWith('.ts')) return; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + log(`File changed: ${filename}`); + restartChild(); + }, 300); +}); + +// Clean exit when VS Code disconnects +process.stdin.on('end', () => { + log('stdin ended — killing child process'); + child?.kill('SIGTERM'); + setTimeout(() => process.exit(0), 500); +}); + +log(`Watching ${srcDir} for changes...`); + +// Initial compile and start +if (compile()) { + spawnChild(); +} else { + log('Initial compilation failed. Fix errors and save a file to retry.'); + // Keep running to watch for file changes +} diff --git a/src/browser.ts b/src/browser.ts index db6429105..3ec75c527 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -34,6 +34,8 @@ import { bridgeAttachDebugger, } from './bridge-client.js'; import {logger} from './logger.js'; +import type {Browser, ConnectionTransport} from './third_party/index.js'; +import {puppeteer} from './third_party/index.js'; // ── CDP Target Types ──────────────────────────────────── @@ -101,6 +103,165 @@ export function sendCdp( }); } +// ── Puppeteer ElectronTransport ───────────────────────── + +/** + * Custom ConnectionTransport that bridges a raw CDP WebSocket to Puppeteer. + * + * Electron's DevTools protocol doesn't support several browser-management + * commands that Puppeteer calls during connect (Target.getBrowserContexts, + * Target.setDiscoverTargets, Target.setAutoAttach, Browser.getVersion). + * This transport intercepts those calls and returns mock responses, while + * forwarding all other CDP commands to the real WebSocket. + * + * Modeled after Puppeteer's own ExtensionTransport which solves the same + * problem for Chrome extensions. + */ +class ElectronTransport implements ConnectionTransport { + onmessage?: (message: string) => void; + onclose?: () => void; + + #ws: WebSocket; + #targetId: string; + #targetUrl: string; + #targetTitle: string; + #versionInfo: CdpVersionInfo; + + constructor(ws: WebSocket, target: CdpTarget, versionInfo: CdpVersionInfo) { + this.#ws = ws; + this.#targetId = target.id; + this.#targetUrl = target.url; + this.#targetTitle = target.title; + this.#versionInfo = versionInfo; + + // Forward real CDP events from the WebSocket to Puppeteer + this.#ws.addEventListener('message', (evt: WebSocket.MessageEvent) => { + const raw = typeof evt.data === 'string' ? evt.data : evt.data.toString(); + const parsed = JSON.parse(raw); + + // Inject sessionId into events so Puppeteer routes to the right page + if (!parsed.id && !parsed.sessionId) { + parsed.sessionId = 'pageTargetSessionId'; + } + + this.onmessage?.(JSON.stringify(parsed)); + }); + + this.#ws.addEventListener('close', () => { + this.onclose?.(); + }); + } + + #dispatchResponse(message: object): void { + setTimeout(() => { + this.onmessage?.(JSON.stringify(message)); + }, 0); + } + + send(message: string): void { + const parsed = JSON.parse(message); + + switch (parsed.method) { + case 'Browser.getVersion': { + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: { + protocolVersion: '1.3', + product: this.#versionInfo.Browser ?? 'Electron', + revision: 'unknown', + userAgent: (this.#versionInfo['User-Agent'] as string) ?? 'Electron', + jsVersion: 'unknown', + }, + }); + return; + } + case 'Target.getBrowserContexts': { + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: { + browserContextIds: [], + }, + }); + return; + } + case 'Target.setDiscoverTargets': { + // Emit a single "page" target representing the VS Code workbench + this.#dispatchResponse({ + method: 'Target.targetCreated', + params: { + targetInfo: { + targetId: this.#targetId, + type: 'page', + title: this.#targetTitle, + url: this.#targetUrl, + attached: false, + canAccessOpener: false, + }, + }, + }); + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: {}, + }); + return; + } + case 'Target.setAutoAttach': { + // Emit attachedToTarget so Puppeteer creates a CDPSession for the page + this.#dispatchResponse({ + method: 'Target.attachedToTarget', + params: { + targetInfo: { + targetId: this.#targetId, + type: 'page', + title: this.#targetTitle, + url: this.#targetUrl, + attached: true, + canAccessOpener: false, + }, + sessionId: 'pageTargetSessionId', + }, + }); + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: {}, + }); + return; + } + } + + // Strip the synthetic sessionId before forwarding to the real WebSocket. + // Our page-level WS doesn't use sessions — Puppeteer adds them for routing. + if (parsed.sessionId === 'pageTargetSessionId') { + delete parsed.sessionId; + } + + // Forward all other commands to the real CDP WebSocket + if (this.#ws.readyState === WebSocket.OPEN) { + this.#ws.send(JSON.stringify(parsed)); + } + } + + close(): void { + // WebSocket lifecycle is managed externally — don't close it here + } +} + +// ── Puppeteer Browser (singleton) ─────────────────────── + +let puppeteerBrowser: Browser | undefined; + +export function getPuppeteerBrowser(): Browser | undefined { + return puppeteerBrowser; +} + // ── Public Getters ────────────────────────────────────── export function getCdpWebSocket(): WebSocket | undefined { @@ -377,6 +538,9 @@ function forceKillChildSync(): void { * Synchronous except for the WS close (best-effort). */ function teardownSync(): void { + // Disconnect Puppeteer before closing the WebSocket + puppeteerBrowser = undefined; + try { cdpWs?.close(); } catch { @@ -510,6 +674,23 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { fs.mkdirSync(userDataDir, {recursive: true}); logger(`User data dir: ${userDataDir}`); + // Pre-write settings to suppress first-run modals. + // Fresh user-data-dir has no persisted state, so the workspace trust dialog + // ("Do you trust the authors?") appears every time and blocks all interaction. + const settingsDir = path.join(userDataDir, 'User'); + fs.mkdirSync(settingsDir, {recursive: true}); + fs.writeFileSync( + path.join(settingsDir, 'settings.json'), + JSON.stringify({ + 'security.workspace.trust.enabled': false, + 'workbench.startupEditor': 'none', + 'workbench.tips.enabled': false, + 'update.showReleaseNotes': false, + 'extensions.ignoreRecommendations': true, + 'telemetry.telemetryLevel': 'off', + }, null, 2), + ); + // 5. Spawn Extension Development Host // `detached: true` is REQUIRED on Windows because Code.exe is a launcher // stub that forks the real Electron binary and immediately exits (code 9). @@ -595,6 +776,21 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { await sendCdp('Page.enable', {}, cdpWs); await waitForWorkbenchReady(cdpWs); + // 9b. Create Puppeteer Browser via ElectronTransport. + // puppeteer.connect({ browserWSEndpoint }) fails on Electron because + // Target.getBrowserContexts is not allowed. ElectronTransport intercepts + // those calls and returns mock responses, while forwarding real CDP. + try { + const transport = new ElectronTransport(cdpWs, workbench, versionInfo); + puppeteerBrowser = await puppeteer.connect({ + transport, + defaultViewport: null, + }); + logger('Puppeteer Browser created via ElectronTransport'); + } catch (err) { + logger(`Warning: Puppeteer connect failed: ${(err as Error).message}. Tools requiring Puppeteer will not work.`); + } + // 10. Discover Dev Host bridge (for VS Code API calls in the target window) devhostBridgePath = (await waitForDevHostBridge(targetFolder)) ?? undefined; @@ -615,6 +811,8 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { * since the process was spawned externally, not via VS Code's launch lifecycle. */ export async function stopDebugWindow(): Promise { + puppeteerBrowser = undefined; + try { cdpWs?.close(); } catch { diff --git a/src/cli.ts b/src/cli.ts index d7ff2d0f2..f50105159 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -63,6 +63,13 @@ export const cliOptions = { default: true, describe: 'Set to false to exclude tools related to network.', }, + dev: { + type: 'boolean', + describe: + 'Dev mode: run from TypeScript source via tsx with automatic file-watching and self-restart on changes.', + default: false, + hidden: true, + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { diff --git a/src/main.ts b/src/main.ts index d7a452e92..60e9125a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,7 @@ import './polyfill.js'; import process from 'node:process'; -import {ensureVSCodeConnected} from './browser.js'; +import {ensureVSCodeConnected, getPuppeteerBrowser} from './browser.js'; import {parseArguments} from './cli.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; @@ -71,11 +71,16 @@ async function ensureConnection(): Promise { /** * Get or create the McpContext (Puppeteer page model). * Only needed for non-diagnostic tools that interact via Puppeteer. - * Phase B will refactor this to work without a Puppeteer Browser. */ async function getContext(): Promise { if (!context) { - context = await McpContext.from(undefined as never, logger, { + const browser = getPuppeteerBrowser(); + if (!browser) { + throw new Error( + 'Puppeteer Browser not available. The ElectronTransport may have failed during connection.', + ); + } + context = await McpContext.from(browser, logger, { experimentalDevToolsDebugging: false, performanceCrux: false, }); From 0deb1fea36505409fe3424cc48df22c1cde01017 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 03:10:41 -0600 Subject: [PATCH 003/127] feat: remove dev supervisor script and add timeout configuration to tools --- scripts/dev-supervisor.mjs | 136 --------------------- src/browser.ts | 9 +- src/logger.ts | 1 + src/main.ts | 215 +++++++++++++++++++++++++++------ src/tools/ToolDefinition.ts | 5 + src/tools/console.ts | 2 + src/tools/debug-bridge-exec.ts | 1 + src/tools/debug-evaluate.ts | 1 + src/tools/input.ts | 8 ++ src/tools/network.ts | 2 + src/tools/performance.ts | 3 + src/tools/screenshot.ts | 1 + src/tools/script.ts | 1 + src/tools/snapshot.ts | 2 + 14 files changed, 214 insertions(+), 173 deletions(-) delete mode 100644 scripts/dev-supervisor.mjs diff --git a/scripts/dev-supervisor.mjs b/scripts/dev-supervisor.mjs deleted file mode 100644 index 1a8f906c9..000000000 --- a/scripts/dev-supervisor.mjs +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env node -/** - * Dev Supervisor for VS Code DevTools MCP Server - * - * This script: - * 1. Runs tsc to compile the TypeScript source - * 2. Spawns the compiled MCP server as a child process - * 3. Proxies stdio between VS Code and the child - * 4. Watches src/ for .ts file changes - * 5. On change: kills child, recompiles, respawns - * - * VS Code never loses the stdio connection — this supervisor owns the pipe. - */ - -import {spawn, execSync} from 'node:child_process'; -import {watch} from 'node:fs'; -import {dirname, join} from 'node:path'; -import {fileURLToPath} from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const projectRoot = dirname(__dirname); // vscode-devtools-mcp/ -const workspaceRoot = dirname(projectRoot); // workspace root with compile script -const srcDir = join(projectRoot, 'src'); -const buildEntry = join(projectRoot, 'build', 'src', 'index.js'); - -// Pass through all args to the child -const childArgs = [buildEntry, ...process.argv.slice(2)]; - -const log = msg => process.stderr.write(`[dev-supervisor] ${msg}\n`); - -let child = null; -let restarting = false; -let debounceTimer = null; -let compiling = false; - -function compile() { - if (compiling) return false; - compiling = true; - try { - log('Compiling TypeScript...'); - execSync('pnpm run compile', { - cwd: workspaceRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }); - log('Compilation complete'); - compiling = false; - return true; - } catch (err) { - log(`Compilation failed: ${err.message}`); - if (err.stderr) log(err.stderr.toString()); - compiling = false; - return false; - } -} - -function spawnChild() { - const proc = spawn(process.execPath, childArgs, { - stdio: ['pipe', 'pipe', 'inherit'], - }); - child = proc; - - // Forward stdin → child (MCP requests from VS Code) - const onStdinData = chunk => { - if (proc.stdin && !proc.stdin.destroyed) { - proc.stdin.write(chunk); - } - }; - process.stdin.on('data', onStdinData); - - // Forward child stdout → stdout (MCP responses to VS Code) - proc.stdout?.on('data', chunk => { - process.stdout.write(chunk); - }); - - proc.on('exit', (code, signal) => { - process.stdin.removeListener('data', onStdinData); - if (restarting) { - // Expected kill during restart — compile and respawn - restarting = false; - if (compile()) { - log('Respawning MCP server...'); - spawnChild(); - } else { - log('Waiting for next file change to retry...'); - } - } else { - // Unexpected exit — propagate - log(`Child exited: code=${code}, signal=${signal}`); - process.exit(code ?? 1); - } - }); - - log(`MCP server started (PID: ${proc.pid})`); -} - -function restartChild() { - if (!child) return; - restarting = true; - log('Killing MCP server for restart...'); - child.kill('SIGTERM'); - // If SIGTERM doesn't work after 2s, force kill (Windows compatibility) - setTimeout(() => { - if (restarting && child && !child.killed) { - log('Force killing with SIGKILL...'); - child.kill('SIGKILL'); - } - }, 2000); -} - -// Watch source directory for .ts changes -watch(srcDir, {recursive: true}, (_event, filename) => { - if (!filename?.toString().endsWith('.ts')) return; - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - log(`File changed: ${filename}`); - restartChild(); - }, 300); -}); - -// Clean exit when VS Code disconnects -process.stdin.on('end', () => { - log('stdin ended — killing child process'); - child?.kill('SIGTERM'); - setTimeout(() => process.exit(0), 500); -}); - -log(`Watching ${srcDir} for changes...`); - -// Initial compile and start -if (compile()) { - spawnChild(); -} else { - log('Initial compilation failed. Fix errors and save a file to retry.'); - // Keep running to watch for file changes -} diff --git a/src/browser.ts b/src/browser.ts index 3ec75c527..ac1597440 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -64,6 +64,11 @@ let launcherPid: number | undefined; let electronPid: number | undefined; let userDataDir: string | undefined; let connectInProgress: Promise | undefined; +let connectionGeneration = 0; + +export function getConnectionGeneration(): number { + return connectionGeneration; +} // ── Raw CDP Communication ─────────────────────────────── @@ -537,7 +542,7 @@ function forceKillChildSync(): void { * Kill any existing child, close WS, and clean up temp dir. * Synchronous except for the WS close (best-effort). */ -function teardownSync(): void { +export function teardownSync(): void { // Disconnect Puppeteer before closing the WebSocket puppeteerBrowser = undefined; @@ -649,6 +654,7 @@ export async function ensureVSCodeConnected( } async function doConnect(options: VSCodeLaunchOptions): Promise { + connectionGeneration++; // Kill any stale child before spawning a new one — no duplicates teardownSync(); @@ -798,6 +804,7 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { cdpWs.on('close', () => { logger('CDP WebSocket closed unexpectedly'); cdpWs = undefined; + puppeteerBrowser = undefined; }); return cdpWs; diff --git a/src/logger.ts b/src/logger.ts index e86af2efc..1649dfa89 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -45,3 +45,4 @@ export function flushLogs( } export const logger = debug(mcpDebugNamespace); + diff --git a/src/main.ts b/src/main.ts index 60e9125a4..87826cebd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,9 +6,13 @@ import './polyfill.js'; +import {execSync} from 'node:child_process'; +import {readdirSync, statSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; import process from 'node:process'; +import {fileURLToPath} from 'node:url'; -import {ensureVSCodeConnected, getPuppeteerBrowser} from './browser.js'; +import {ensureVSCodeConnected, getConnectionGeneration, getPuppeteerBrowser, teardownSync} from './browser.js'; import {parseArguments} from './cli.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; @@ -25,6 +29,33 @@ import {ToolCategory} from './tools/categories.js'; import type {ToolDefinition} from './tools/ToolDefinition.js'; import {tools} from './tools/tools.js'; +// Default timeout for tools (30 seconds) +const DEFAULT_TOOL_TIMEOUT_MS = 30_000; + +class ToolTimeoutError extends Error { + constructor(toolName: string, timeoutMs: number) { + super( + `Tool "${toolName}" timed out after ${timeoutMs}ms. The operation took too long to complete.`, + ); + this.name = 'ToolTimeoutError'; + } +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + toolName: string, +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new ToolTimeoutError(toolName, timeoutMs)); + }, timeoutMs); + }), + ]); +} + // If moved update release-please config // x-release-please-start-version const VERSION = '0.16.0'; @@ -54,11 +85,13 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => { }); let context: McpContext | undefined; +let contextGeneration = -1; /** * Ensure VS Code debug window is connected (CDP + bridge). * Required for ALL tools including diagnostic ones. */ + async function ensureConnection(): Promise { await ensureVSCodeConnected({ workspaceFolder: args.folder as string, @@ -68,11 +101,90 @@ async function ensureConnection(): Promise { }); } +// ── Dev-mode lazy rebuild ────────────────────────────────── +// Resolves paths once at startup. At build time this file lives at +// build/src/main.js, so two dirname() calls reach the project root. +const __filename = fileURLToPath(import.meta.url); +const projectRoot = dirname(dirname(dirname(__filename))); +const srcDir = join(projectRoot, 'src'); +// Sentinel file stamped after each successful build — tsc may not update +// output mtimes when content is unchanged, so we use our own marker. +const buildSentinel = join(projectRoot, 'build', '.dev-build-stamp'); + +function getNewestMtime(dir: string, ext: string): number { + let newest = 0; + for (const entry of readdirSync(dir, {withFileTypes: true, recursive: true})) { + if (!entry.isFile() || !entry.name.endsWith(ext)) continue; + const fullPath = join(entry.parentPath ?? (entry as any).path ?? dir, entry.name); + const mtime = statSync(fullPath).mtimeMs; + if (mtime > newest) newest = mtime; + } + return newest; +} + +class DevRebuildNeeded extends Error { + constructor(public detail: string) { + super(detail); + this.name = 'DevRebuildNeeded'; + } +} + +/** + * In dev mode, checks if any .ts source files are newer than the build output. + * If so, recompiles. On success, throws DevRebuildNeeded (the tool wrapper + * returns a message and the server exits). On failure, throws with build errors. + */ +function devLazyRebuildCheck(): void { + if (!args.dev) return; + + let buildMtime: number; + try { + buildMtime = statSync(buildSentinel).mtimeMs; + } catch { + // No sentinel yet — force rebuild + buildMtime = 0; + } + + const srcMtime = getNewestMtime(srcDir, '.ts'); + if (srcMtime <= buildMtime) return; + + logger('[dev] Source files changed — recompiling...'); + try { + execSync('pnpm run build', { + cwd: projectRoot, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + // Stamp the sentinel so we don't rebuild again until source changes + writeFileSync(buildSentinel, new Date().toISOString(), 'utf-8'); + } catch (err: any) { + const stderr = err.stderr?.toString() ?? ''; + const stdout = err.stdout?.toString() ?? ''; + const output = [stderr, stdout].filter(Boolean).join('\n'); + throw new Error( + `[dev] Build failed. Fix the errors and try again:\n${output}`, + ); + } + + logger('[dev] Build successful — server must restart to load new code.'); + throw new DevRebuildNeeded( + 'Source code was modified and has been rebuilt successfully. ' + + 'The server will now exit so the new code can be loaded. ' + + 'Please restart the MCP server and call the tool again.', + ); +} + /** * Get or create the McpContext (Puppeteer page model). * Only needed for non-diagnostic tools that interact via Puppeteer. */ async function getContext(): Promise { + const gen = getConnectionGeneration(); + if (gen !== contextGeneration) { + context?.dispose(); + context = undefined; + contextGeneration = gen; + } if (!context) { const browser = getPuppeteerBrowser(); if (!browser) { @@ -132,44 +244,60 @@ function registerTool(tool: ToolDefinition): void { annotations: tool.annotations, }, async (params): Promise => { - const guard = await toolMutex.acquire(); + const timeoutMs = tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS; + let guard: InstanceType | undefined; try { - logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); - - // Always ensure VS Code connection (CDP + bridge) - await ensureConnection(); - - // Diagnostic tools bypass McpContext — they use sendCdp/bridgeExec directly. - // Non-diagnostic tools need full McpContext (Phase B will refactor this). - const isDiagnostic = tool.annotations.conditions?.includes('devDiagnostic'); - const ctx = isDiagnostic ? (undefined as never) : await getContext(); - - logger(`${tool.name} context: resolved`); - const response = new McpResponse(); - await tool.handler( - { - params, - }, - response, - ctx, - ); - - // Diagnostic tools return content directly without McpResponse.handle() - if (isDiagnostic) { - const textContent: Array<{type: 'text'; text: string}> = []; - for (const line of response.responseLines) { - textContent.push({type: 'text', text: line}); - } - if (textContent.length === 0) { - textContent.push({type: 'text', text: '(no output)'}); + // In dev mode, check if source files changed and rebuild before executing. + // This runs OUTSIDE the mutex so it doesn't block on stale locks. + devLazyRebuildCheck(); + + const executeAll = async () => { + guard = await toolMutex.acquire(); + logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); + + // Always ensure VS Code connection (CDP + bridge) + await ensureConnection(); + + // Diagnostic tools bypass McpContext — they use sendCdp/bridgeExec directly. + // Non-diagnostic tools need full McpContext (Phase B will refactor this). + const isDiagnostic = tool.annotations.conditions?.includes('devDiagnostic'); + const ctx = isDiagnostic ? (undefined as never) : await getContext(); + + logger(`${tool.name} context: resolved`); + const response = new McpResponse(); + await tool.handler( + { + params, + }, + response, + ctx, + ); + + // Diagnostic tools return content directly without McpResponse.handle() + if (isDiagnostic) { + const textContent: Array<{type: 'text'; text: string}> = []; + for (const line of response.responseLines) { + textContent.push({type: 'text', text: line}); + } + if (textContent.length === 0) { + textContent.push({type: 'text', text: '(no output)'}); + } + return {content: textContent}; } - return {content: textContent}; - } - const {content, structuredContent} = await response.handle( + const {content, structuredContent} = await response.handle( + tool.name, + ctx, + ); + return {content, structuredContent}; + }; + + const {content, structuredContent} = await withTimeout( + executeAll(), + timeoutMs, tool.name, - ctx, - ); + ) as {content: CallToolResult['content']; structuredContent?: Record}; + const result: CallToolResult & { structuredContent?: Record; } = { @@ -183,6 +311,19 @@ function registerTool(tool: ToolDefinition): void { } return result; } catch (err) { + // Dev rebuild succeeded — return message and exit so new code loads on next start + if (err instanceof DevRebuildNeeded) { + logger(`${tool.name}: ${err.detail}`); + // Schedule exit after response is sent + setTimeout(() => { + teardownSync(); + process.exit(0); + }, 500); + return { + content: [{type: 'text', text: err.detail}], + isError: false, + }; + } logger(`${tool.name} error:`, err, err?.stack); let errorText = err && 'message' in err ? err.message : String(err); if ('cause' in err && err.cause) { @@ -198,7 +339,7 @@ function registerTool(tool: ToolDefinition): void { isError: true, }; } finally { - guard.dispose(); + guard?.dispose(); } }, ); @@ -213,3 +354,5 @@ const transport = new StdioServerTransport(); await server.connect(transport); logger('VS Code DevTools MCP Server connected'); logDisclaimers(); + + diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index e698591e9..fb4f920b2 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -23,6 +23,11 @@ export interface ToolDefinition< > { name: string; description: string; + /** + * Maximum time in milliseconds for this tool to complete. + * If not specified, defaults to 30000 (30 seconds). + */ + timeoutMs?: number; annotations: { title?: string; category: ToolCategory; diff --git a/src/tools/console.ts b/src/tools/console.ts index ace8f6282..9f0472bef 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -41,6 +41,7 @@ export const listConsoleMessages = defineTool({ name: 'list_console_messages', description: 'List all console messages for the currently selected page since the last navigation.', + timeoutMs: 15000, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: true, @@ -89,6 +90,7 @@ export const listConsoleMessages = defineTool({ export const getConsoleMessage = defineTool({ name: 'get_console_message', description: `Gets a console message by its ID. You can get all messages by calling ${listConsoleMessages.name}.`, + timeoutMs: 10000, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: true, diff --git a/src/tools/debug-bridge-exec.ts b/src/tools/debug-bridge-exec.ts index cc18c7856..92ca73235 100644 --- a/src/tools/debug-bridge-exec.ts +++ b/src/tools/debug-bridge-exec.ts @@ -38,6 +38,7 @@ Examples: - \`return vscode.window.tabGroups.all.flatMap(g => g.tabs.map(t => ({label: t.label, active: t.isActive})));\` — list editor tabs - \`const editor = vscode.window.activeTextEditor; return editor ? { file: editor.document.fileName, line: editor.selection.active.line } : null;\` — get active editor info - \`return vscode.extensions.all.filter(e => e.isActive).map(e => e.id);\` — list active extensions`, + timeoutMs: 10000, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, diff --git a/src/tools/debug-evaluate.ts b/src/tools/debug-evaluate.ts index f24c1bf17..894d50e8f 100644 --- a/src/tools/debug-evaluate.ts +++ b/src/tools/debug-evaluate.ts @@ -30,6 +30,7 @@ Examples: - \`document.querySelector('.monaco-workbench')?.className\` — check workbench state - \`JSON.stringify(performance.getEntriesByType('navigation'))\` — navigation timing - \`Array.from(document.querySelectorAll('.notification-toast')).map(n => n.textContent)\` — list notifications`, + timeoutMs: 10000, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, diff --git a/src/tools/input.ts b/src/tools/input.ts index cd8b2f4d0..942b260ce 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -36,6 +36,7 @@ function handleActionError(error: unknown, uid: string) { export const click = defineTool({ name: 'click', description: `Clicks on the provided element`, + timeoutMs: 10000, annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -77,6 +78,7 @@ export const click = defineTool({ export const clickAt = defineTool({ name: 'click_at', description: `Clicks at the provided coordinates`, + timeoutMs: 10000, annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -109,6 +111,7 @@ export const clickAt = defineTool({ export const hover = defineTool({ name: 'hover', description: `Hover over the provided element`, + timeoutMs: 10000, annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -205,6 +208,7 @@ async function fillFormElement( export const fill = defineTool({ name: 'fill', description: `Type text into a input, text area or select an option from a element.`, @@ -212,6 +128,7 @@ export const fill = defineTool({ annotations: { category: ToolCategory.INPUT, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { uid: zod @@ -222,18 +139,10 @@ export const fill = defineTool({ value: zod.string().describe('The value to fill in'), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { - await context.waitForEventsAfterAction(async () => { - await fillFormElement( - request.params.uid, - request.params.value, - context as McpContext, - ); - }); - response.appendResponseLine(`Successfully filled out the element`); - if (request.params.includeSnapshot) { - response.includeSnapshot(); - } + handler: async (request, response) => { + await fillElement(request.params.uid, request.params.value); + response.appendResponseLine('Successfully filled out the element'); + await maybeSnapshot(request.params.includeSnapshot, response); }, }); @@ -244,29 +153,17 @@ export const drag = defineTool({ annotations: { category: ToolCategory.INPUT, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { from_uid: zod.string().describe('The uid of the element to drag'), to_uid: zod.string().describe('The uid of the element to drop into'), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { - const fromHandle = await context.getElementByUid(request.params.from_uid); - const toHandle = await context.getElementByUid(request.params.to_uid); - try { - await context.waitForEventsAfterAction(async () => { - await fromHandle.drag(toHandle); - await new Promise(resolve => setTimeout(resolve, 50)); - await toHandle.drop(fromHandle); - }); - response.appendResponseLine(`Successfully dragged an element`); - if (request.params.includeSnapshot) { - response.includeSnapshot(); - } - } finally { - void fromHandle.dispose(); - void toHandle.dispose(); - } + handler: async (request, response) => { + await dragElement(request.params.from_uid, request.params.to_uid); + response.appendResponseLine('Successfully dragged an element'); + await maybeSnapshot(request.params.includeSnapshot, response); }, }); @@ -277,6 +174,7 @@ export const fillForm = defineTool({ annotations: { category: ToolCategory.INPUT, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { elements: zod @@ -289,20 +187,12 @@ export const fillForm = defineTool({ .describe('Elements from snapshot to fill out.'), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response) => { for (const element of request.params.elements) { - await context.waitForEventsAfterAction(async () => { - await fillFormElement( - element.uid, - element.value, - context as McpContext, - ); - }); - } - response.appendResponseLine(`Successfully filled out the form`); - if (request.params.includeSnapshot) { - response.includeSnapshot(); + await fillElement(element.uid, element.value); } + response.appendResponseLine('Successfully filled out the form'); + await maybeSnapshot(request.params.includeSnapshot, response); }, }); @@ -313,6 +203,7 @@ export const uploadFile = defineTool({ annotations: { category: ToolCategory.INPUT, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { uid: zod @@ -323,48 +214,28 @@ export const uploadFile = defineTool({ filePath: zod.string().describe('The local path of the file to upload'), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { + handler: async (request, response) => { const {uid, filePath} = request.params; - const handle = (await context.getElementByUid( - uid, - )) as ElementHandle; - try { - try { - await handle.uploadFile(filePath); - } catch { - // Some sites use a proxy element to trigger file upload instead of - // a type=file element. In this case, we want to default to - // Page.waitForFileChooser() and upload the file this way. - try { - const page = context.getSelectedPage(); - const [fileChooser] = await Promise.all([ - page.waitForFileChooser({timeout: 3000}), - handle.asLocator().click(), - ]); - await fileChooser.accept([filePath]); - } catch { - throw new Error( - `Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.`, - ); - } - } - if (request.params.includeSnapshot) { - response.includeSnapshot(); - } - response.appendResponseLine(`File uploaded from ${filePath}.`); - } finally { - void handle.dispose(); - } + // Use DOM.setFileInputFiles for file input elements + const backendNodeId = (await import('../ax-tree.js')).getBackendNodeId(uid); + await sendCdp('DOM.enable'); + await sendCdp('DOM.setFileInputFiles', { + files: [filePath], + backendNodeId, + }); + response.appendResponseLine(`File uploaded from ${filePath}.`); + await maybeSnapshot(request.params.includeSnapshot, response); }, }); -export const pressKey = defineTool({ +export const pressKeyTool = defineTool({ name: 'press_key', description: `Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).`, timeoutMs: 10000, annotations: { category: ToolCategory.INPUT, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { key: zod @@ -374,26 +245,11 @@ export const pressKey = defineTool({ ), includeSnapshot: includeSnapshotSchema, }, - handler: async (request, response, context) => { - const page = context.getSelectedPage(); - const tokens = parseKey(request.params.key); - const [key, ...modifiers] = tokens; - - await context.waitForEventsAfterAction(async () => { - for (const modifier of modifiers) { - await page.keyboard.down(modifier); - } - await page.keyboard.press(key); - for (const modifier of modifiers.toReversed()) { - await page.keyboard.up(modifier); - } - }); - + handler: async (request, response) => { + await pressKey(request.params.key); response.appendResponseLine( `Successfully pressed key: ${request.params.key}`, ); - if (request.params.includeSnapshot) { - response.includeSnapshot(); - } + await maybeSnapshot(request.params.includeSnapshot, response); }, }); diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index e0492dae5..8157db29d 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {writeFileSync} from 'fs'; + +import {captureScreenshot} from '../ax-tree.js'; import {zod} from '../third_party/index.js'; -import type {ElementHandle, Page} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; @@ -16,8 +18,8 @@ export const screenshot = defineTool({ timeoutMs: 10000, annotations: { category: ToolCategory.DEBUGGING, - // Not read-only due to filePath param. readOnlyHint: false, + conditions: ['directCdp'], }, schema: { format: zod @@ -51,26 +53,19 @@ export const screenshot = defineTool({ 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.', ), }, - handler: async (request, response, context) => { + handler: async (request, response) => { if (request.params.uid && request.params.fullPage) { throw new Error('Providing both "uid" and "fullPage" is not allowed.'); } - let pageOrHandle: Page | ElementHandle; - if (request.params.uid) { - pageOrHandle = await context.getElementByUid(request.params.uid); - } else { - pageOrHandle = context.getSelectedPage(); - } - const format = request.params.format; const quality = format === 'png' ? undefined : request.params.quality; - const screenshot = await pageOrHandle.screenshot({ - type: format, - fullPage: request.params.fullPage, + const data = await captureScreenshot({ + format, quality, - optimizeForSpeed: true, // Bonus: optimize encoding for speed + uid: request.params.uid, + fullPage: request.params.fullPage, }); if (request.params.uid) { @@ -88,18 +83,16 @@ export const screenshot = defineTool({ } if (request.params.filePath) { - const file = await context.saveFile(screenshot, request.params.filePath); - response.appendResponseLine(`Saved screenshot to ${file.filename}.`); - } else if (screenshot.length >= 2_000_000) { - const {filename} = await context.saveTemporaryFile( - screenshot, - `image/${request.params.format}`, - ); - response.appendResponseLine(`Saved screenshot to ${filename}.`); + writeFileSync(request.params.filePath, data); + response.appendResponseLine(`Saved screenshot to ${request.params.filePath}.`); + } else if (data.length >= 2_000_000) { + const tmpPath = `screenshot-${Date.now()}.${format}`; + writeFileSync(tmpPath, data); + response.appendResponseLine(`Saved screenshot to ${tmpPath}.`); } else { response.attachImage({ - mimeType: `image/${request.params.format}`, - data: Buffer.from(screenshot).toString('base64'), + mimeType: `image/${format}`, + data: data.toString('base64'), }); } }, diff --git a/src/tools/script.ts b/src/tools/script.ts index 1edb8481d..b9d19936f 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {resolveNodeToRemoteObject} from '../ax-tree.js'; +import {sendCdp} from '../browser.js'; import {zod} from '../third_party/index.js'; -import type {Frame, JSHandle, Page} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; @@ -18,6 +19,7 @@ so returned values have to be JSON-serializable.`, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { function: zod.string().describe( @@ -45,43 +47,64 @@ Example with arguments: \`(el) => { .optional() .describe(`An optional list of arguments to pass to the function.`), }, - handler: async (request, response, context) => { - const args: Array> = []; + handler: async (request, response) => { + const argObjectIds: string[] = []; try { - const frames = new Set(); for (const el of request.params.args ?? []) { - const handle = await context.getElementByUid(el.uid); - frames.add(handle.frame); - args.push(handle); + const objectId = await resolveNodeToRemoteObject(el.uid); + argObjectIds.push(objectId); } - let pageOrFrame: Page | Frame; - // We can't evaluate the element handle across frames - if (frames.size > 1) { - throw new Error( - "Elements from different frames can't be evaluated together.", - ); + + // Build a Runtime.callFunctionOn expression + // If we have element args, call on the first arg's context + // Otherwise call on the global (no objectId → evaluates in page context) + const fnSource = request.params.function; + const callArgs = argObjectIds.map(id => ({objectId: id})); + + let result: string; + + if (argObjectIds.length > 0) { + // Use callFunctionOn with the first element as `this`, rest as args + const wrapper = `async function(__fn, ...args) { return JSON.stringify(await (${fnSource})(...args)); }`; + const callResult = await sendCdp('Runtime.callFunctionOn', { + functionDeclaration: wrapper, + objectId: argObjectIds[0], + arguments: callArgs, + returnByValue: true, + awaitPromise: true, + }); + if (callResult.exceptionDetails) { + const desc = + callResult.exceptionDetails.exception?.description ?? + callResult.exceptionDetails.text; + throw new Error(`Script error: ${desc}`); + } + result = callResult.result?.value ?? 'undefined'; } else { - pageOrFrame = [...frames.values()][0] ?? context.getSelectedPage(); + // No args — use Runtime.evaluate in page context + const expression = `(async () => { const __fn = (${fnSource}); return JSON.stringify(await __fn()); })()`; + const evalResult = await sendCdp('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (evalResult.exceptionDetails) { + const desc = + evalResult.exceptionDetails.exception?.description ?? + evalResult.exceptionDetails.text; + throw new Error(`Script error: ${desc}`); + } + result = evalResult.result?.value ?? 'undefined'; } - const fn = await pageOrFrame.evaluateHandle( - `(${request.params.function})`, - ); - args.unshift(fn); - await context.waitForEventsAfterAction(async () => { - const result = await pageOrFrame.evaluate( - async (fn, ...args) => { - // @ts-expect-error no types. - return JSON.stringify(await fn(...args)); - }, - ...args, - ); - response.appendResponseLine('Script ran on page and returned:'); - response.appendResponseLine('```json'); - response.appendResponseLine(`${result}`); - response.appendResponseLine('```'); - }); + + response.appendResponseLine('Script ran on page and returned:'); + response.appendResponseLine('```json'); + response.appendResponseLine(`${result}`); + response.appendResponseLine('```'); } finally { - void Promise.allSettled(args.map(arg => arg.dispose())); + for (const objectId of argObjectIds) { + void sendCdp('Runtime.releaseObject', {objectId}).catch(() => {}); + } } }, }); diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 2b9b498d8..d0400087a 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -6,150 +6,12 @@ import {writeFileSync} from 'fs'; -import {sendCdp} from '../browser.js'; +import {fetchAXTree} from '../ax-tree.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool, timeoutSchema} from './ToolDefinition.js'; -// ── CDP Accessibility Types ── - -interface AXValue { - type: string; - value?: string | number | boolean; -} - -interface AXProperty { - name: string; - value: AXValue; -} - -interface AXNode { - nodeId: string; - ignored?: boolean; - role?: AXValue; - name?: AXValue; - description?: AXValue; - value?: AXValue; - properties?: AXProperty[]; - childIds?: string[]; - parentId?: string; - backendDOMNodeId?: number; -} - -// ── Formatting ── - -const BOOLEAN_PROPERTY_MAP: Record = { - disabled: 'disableable', - expanded: 'expandable', - focused: 'focusable', - selected: 'selectable', -}; - -/** -/** - * Roles that are purely structural or presentational — skipped in non-verbose - * mode with their children promoted to the parent depth. - */ -const UNINTERESTING_ROLES = new Set([ - 'generic', - 'none', - 'InlineTextBox', - 'StaticText', - 'LineBreak', - 'paragraph', - 'group', -]); - -/** - * In non-verbose mode a node is "interesting" if it has a semantic role - * (not structural noise) OR carries a meaningful name on a named container. - */ -function isInteresting(node: AXNode): boolean { - if (node.ignored) return false; - const role = String(node.role?.value ?? ''); - if (UNINTERESTING_ROLES.has(role)) return false; - return true; -} - -/** - * Format a flat CDP AX node array into an indented uid-based text tree. - * - * verbose=true → every node, including structural/ignored ones (full detail) - * verbose=false → only semantically interesting nodes (human-readable) - */ -function formatAXTree(nodes: AXNode[], verbose: boolean): string { - const nodeMap = new Map(); - for (const node of nodes) { - nodeMap.set(node.nodeId, node); - } - - const roots = nodes.filter(n => !n.parentId); - - let uidCounter = 0; - - function formatNode(node: AXNode, depth: number): string { - const include = verbose || isInteresting(node); - - let result = ''; - if (include) { - const uid = `s0_${uidCounter++}`; - const parts: string[] = [`uid=${uid}`]; - - const role = node.role?.value; - if (role) { - parts.push(role === 'none' ? 'ignored' : String(role)); - } - - if (node.name?.value) { - parts.push(`"${node.name.value}"`); - } - - if (node.properties) { - for (const prop of node.properties) { - const mapped = BOOLEAN_PROPERTY_MAP[prop.name]; - if (prop.value.type === 'boolean' || prop.value.type === 'booleanOrUndefined') { - if (prop.value.value) { - if (mapped) parts.push(mapped); - parts.push(prop.name); - } - } else if (typeof prop.value.value === 'string') { - parts.push(`${prop.name}="${prop.value.value}"`); - } else if (typeof prop.value.value === 'number') { - parts.push(`${prop.name}="${prop.value.value}"`); - } - } - } - - if (node.value?.value !== undefined && node.value.value !== '') { - parts.push(`value="${node.value.value}"`); - } - - const indent = ' '.repeat(depth * 2); - result += `${indent}${parts.join(' ')}\n`; - } - - // Recurse into children — promote children if parent was skipped - const childDepth = include ? depth + 1 : depth; - if (node.childIds) { - for (const childId of node.childIds) { - const child = nodeMap.get(childId); - if (child) { - result += formatNode(child, childDepth); - } - } - } - - return result; - } - - let output = ''; - for (const root of roots) { - output += formatNode(root, 0); - } - return output || '(empty accessibility tree)'; -} - // ── Tools ── export const takeSnapshot = defineTool({ @@ -181,12 +43,7 @@ in the DevTools Elements panel (if any).`, const verbose = request.params.verbose ?? false; const filePath = request.params.filePath; - // Enable Accessibility domain then fetch the full AX tree via CDP - await sendCdp('Accessibility.enable'); - const result = await sendCdp('Accessibility.getFullAXTree'); - const nodes: AXNode[] = result.nodes ?? []; - - const formatted = formatAXTree(nodes, verbose); + const {formatted} = await fetchAXTree(verbose); if (filePath) { writeFileSync(filePath, formatted, 'utf-8'); @@ -218,9 +75,7 @@ export const waitFor = defineTool({ const start = Date.now(); while (Date.now() - start < timeout) { - await sendCdp('Accessibility.enable'); - const result = await sendCdp('Accessibility.getFullAXTree'); - const nodes: AXNode[] = result.nodes ?? []; + const {formatted, nodes} = await fetchAXTree(false); const found = nodes.some( n => @@ -233,8 +88,6 @@ export const waitFor = defineTool({ response.appendResponseLine( `Element with text "${text}" found.`, ); - - const formatted = formatAXTree(nodes, false); response.appendResponseLine('## Latest page snapshot'); response.appendResponseLine(formatted); return; diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 06a752148..8e8f2340d 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -12,13 +12,13 @@ import {describe, it} from 'node:test'; import {McpResponse} from '../../src/McpResponse.js'; import { click, + clickAt, hover, fill, drag, fillForm, uploadFile, - pressKey, - clickAt, + pressKeyTool, } from '../../src/tools/input.js'; import {parseKey} from '../../src/utils/keyboard.js'; import {serverHooks} from '../server.js'; @@ -234,6 +234,7 @@ describe('input', () => { }); }); + // clickAt tests use Puppeteer context — will need directCdp equivalents describe('click_at', () => { it('clicks at coordinates', async () => { await withMcpContext(async (response, context) => { @@ -730,7 +731,7 @@ describe('input', () => { ); await context.createTextSnapshot(); - await pressKey.handler( + await pressKeyTool.handler( { params: { key: 'Control+Shift+C', From de0c3bcc43bd8c473cabf180859601a5177ccb72 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 04:06:44 -0600 Subject: [PATCH 006/127] feat: enhance input tools to execute actions with diff or full snapshot options --- src/ax-tree.ts | 301 ++++++++++++++++++++++++++++++++++++++++ src/tools/input.ts | 124 +++++++++++------ src/tools/screenshot.ts | 3 +- 3 files changed, 388 insertions(+), 40 deletions(-) diff --git a/src/ax-tree.ts b/src/ax-tree.ts index 6aa7d9429..2c429f97a 100644 --- a/src/ax-tree.ts +++ b/src/ax-tree.ts @@ -625,3 +625,304 @@ export async function captureScreenshot(options: { const result = await sendCdp('Page.captureScreenshot', params); return Buffer.from(result.data, 'base64'); } + +// ── Snapshot Diff ── + +/** + * Represents a node for comparison purposes. + * Uses backendDOMNodeId as the stable identifier. + */ +export interface NodeSignature { + backendDOMNodeId: number; + role: string; + name: string; + value: string; + focused: boolean; + expanded: boolean; + selected: boolean; +} + +/** + * Create a signature string for comparison. + */ +function getNodeSignature(node: AXNode): NodeSignature | null { + if (node.backendDOMNodeId === undefined) return null; + const props = node.properties ?? []; + return { + backendDOMNodeId: node.backendDOMNodeId, + role: String(node.role?.value ?? ''), + name: String(node.name?.value ?? ''), + value: String(node.value?.value ?? ''), + focused: props.some(p => p.name === 'focused' && p.value.value === true), + expanded: props.some(p => p.name === 'expanded' && p.value.value === true), + selected: props.some(p => p.name === 'selected' && p.value.value === true), + }; +} + +/** + * Format a node as a single-line summary. + */ +function formatNodeOneLiner(node: AXNode, uid: string): string { + const parts: string[] = [`uid=${uid}`]; + const role = node.role?.value; + if (role && role !== 'none') parts.push(String(role)); + if (node.name?.value) parts.push(`"${node.name.value}"`); + const props = node.properties ?? []; + if (props.some(p => p.name === 'focused' && p.value.value)) parts.push('focused'); + if (props.some(p => p.name === 'expanded' && p.value.value)) parts.push('expanded'); + if (props.some(p => p.name === 'selected' && p.value.value)) parts.push('selected'); + if (node.value?.value) parts.push(`value="${node.value.value}"`); + return parts.join(' '); +} + +export interface SnapshotDiff { + /** Nodes that appeared in the after snapshot. */ + added: string[]; + /** Nodes that disappeared in the after snapshot. */ + removed: string[]; + /** Nodes whose properties changed (with before→after). */ + changed: string[]; + /** True if there were any changes. */ + hasChanges: boolean; +} + +/** + * Compare two AX tree snapshots and return the diff. + * Both snapshots should be captured with fetchAXTreeForDiff. + */ +export function diffSnapshots( + before: Map, + after: Map, +): SnapshotDiff { + const added: string[] = []; + const removed: string[] = []; + const changed: string[] = []; + + // Check for additions and changes + for (const [domId, afterData] of after) { + const beforeData = before.get(domId); + if (!beforeData) { + // New node + added.push(formatNodeOneLiner(afterData.node, afterData.uid)); + } else { + // Check for changes in key properties + const bSig = beforeData.sig; + const aSig = afterData.sig; + const changes: string[] = []; + + if (bSig.name !== aSig.name) { + changes.push(`name: "${bSig.name}" → "${aSig.name}"`); + } + if (bSig.value !== aSig.value) { + changes.push(`value: "${bSig.value}" → "${aSig.value}"`); + } + if (bSig.focused !== aSig.focused) { + changes.push(aSig.focused ? '+focused' : '-focused'); + } + if (bSig.expanded !== aSig.expanded) { + changes.push(aSig.expanded ? '+expanded' : '-expanded'); + } + if (bSig.selected !== aSig.selected) { + changes.push(aSig.selected ? '+selected' : '-selected'); + } + + if (changes.length > 0) { + const base = formatNodeOneLiner(afterData.node, afterData.uid); + changed.push(`${base} (${changes.join(', ')})`); + } + } + } + + // Check for removals (in before but not in after) + for (const [domId, beforeData] of before) { + if (!after.has(domId)) { + // Pick a placeholder UID for removed nodes + const role = String(beforeData.node.role?.value ?? ''); + const name = beforeData.node.name?.value ? ` "${beforeData.node.name.value}"` : ''; + removed.push(`${role}${name} [removed]`); + } + } + + return { + added, + removed, + changed, + hasChanges: added.length > 0 || removed.length > 0 || changed.length > 0, + }; +} + +/** + * Fetch AX tree for diffing — returns a map keyed by backendDOMNodeId. + * Does NOT update the global UID mapping. + * Only includes "interesting" nodes (same filter as the formatted output). + */ +export async function fetchAXTreeForDiff(): Promise> { + await sendCdp('Accessibility.enable'); + const result = await sendCdp('Accessibility.getFullAXTree'); + const nodes: AXNode[] = result.nodes ?? []; + + const map = new Map(); + for (const node of nodes) { + if (node.ignored) continue; + if (!isInteresting(node)) continue; + const sig = getNodeSignature(node); + if (sig) { + map.set(sig.backendDOMNodeId, {node, sig}); + } + } + return map; +} + +/** + * Capture AX tree after an action, with UIDs assigned. + * Updates global UID mapping and returns a map for diffing. + */ +export async function fetchAXTreeForDiffWithUids(): Promise<{ + map: Map; + formatted: string; +}> { + await sendCdp('Accessibility.enable'); + const result = await sendCdp('Accessibility.getFullAXTree'); + const nodes: AXNode[] = result.nodes ?? []; + + // Build CDP node map + const cdpMap = new Map(); + for (const node of nodes) { + cdpMap.set(node.nodeId, node); + } + + // Assign UIDs and build diff map + const newUidMap = new Map(); + const diffMap = new Map(); + let uidCounter = 0; + + const roots = nodes.filter(n => !n.parentId); + + function visit(node: AXNode): void { + if (!node.ignored && isInteresting(node)) { + const uid = `s0_${uidCounter++}`; + newUidMap.set(uid, node); + const sig = getNodeSignature(node); + if (sig) { + diffMap.set(sig.backendDOMNodeId, {node, sig, uid}); + } + } + for (const childId of node.childIds ?? []) { + const child = cdpMap.get(childId); + if (child) visit(child); + } + } + + for (const root of roots) { + visit(root); + } + + // Update global state + uidToAXNode = newUidMap; + cdpNodeMap = cdpMap; + + // Build formatted output (reuse the logic but simpler) + let formatted = ''; + uidCounter = 0; + function formatVisit(node: AXNode, depth: number): void { + if (!node.ignored && isInteresting(node)) { + const uid = `s0_${uidCounter++}`; + const indent = ' '.repeat(depth * 2); + formatted += `${indent}${formatNodeOneLiner(node, uid)}\n`; + } + const childDepth = !node.ignored && isInteresting(node) ? depth + 1 : depth; + for (const childId of node.childIds ?? []) { + const child = cdpMap.get(childId); + if (child) formatVisit(child, childDepth); + } + } + for (const root of roots) { + formatVisit(root, 0); + } + + return {map: diffMap, formatted}; +} + +/** + * Poll for changes after an action, up to the specified timeout. + * Returns the diff between before and after states. + */ +export async function waitForChanges( + beforeMap: Map, + timeoutMs = 1500, + pollIntervalMs = 100, +): Promise<{diff: SnapshotDiff; formatted: string}> { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const {map: afterMap, formatted} = await fetchAXTreeForDiffWithUids(); + const diff = diffSnapshots(beforeMap, afterMap); + + if (diff.hasChanges) { + return {diff, formatted}; + } + + await new Promise(r => setTimeout(r, pollIntervalMs)); + } + + // Final check after timeout + const {map: afterMap, formatted} = await fetchAXTreeForDiffWithUids(); + const diff = diffSnapshots(beforeMap, afterMap); + return {diff, formatted}; +} + +/** + * Execute an action and return the UI diff. + * This is the main helper for interactive tools. + */ +export async function executeWithDiff( + action: () => Promise, + timeoutMs = 1500, +): Promise<{result: T; diff: SnapshotDiff; summary: string}> { + // Capture before state + const beforeMap = await fetchAXTreeForDiff(); + + // Execute the action + const result = await action(); + + // Wait for changes + const {diff} = await waitForChanges(beforeMap, timeoutMs); + + // Build summary + let summary = ''; + if (!diff.hasChanges) { + summary = 'No visible changes detected.'; + } else { + const lines: string[] = []; + if (diff.added.length > 0) { + lines.push(`Added (${diff.added.length}):`); + for (const item of diff.added.slice(0, 10)) { + lines.push(` + ${item}`); + } + if (diff.added.length > 10) { + lines.push(` ... and ${diff.added.length - 10} more`); + } + } + if (diff.removed.length > 0) { + lines.push(`Removed (${diff.removed.length}):`); + for (const item of diff.removed.slice(0, 10)) { + lines.push(` - ${item}`); + } + if (diff.removed.length > 10) { + lines.push(` ... and ${diff.removed.length - 10} more`); + } + } + if (diff.changed.length > 0) { + lines.push(`Changed (${diff.changed.length}):`); + for (const item of diff.changed.slice(0, 10)) { + lines.push(` ~ ${item}`); + } + if (diff.changed.length > 10) { + lines.push(` ... and ${diff.changed.length - 10} more`); + } + } + summary = lines.join('\n'); + } + + return {result, diff, summary}; +} diff --git a/src/tools/input.ts b/src/tools/input.ts index 28673812c..8909728e3 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -8,6 +8,7 @@ import { clickAtCoords, clickElement, dragElement, + executeWithDiff, fetchAXTree, fillElement, hoverElement, @@ -29,15 +30,29 @@ const includeSnapshotSchema = zod .optional() .describe('Whether to include a snapshot in the response. Default is false.'); -async function maybeSnapshot( +/** + * Execute an action and show either the diff or full snapshot. + * Always shows diff by default; if includeSnapshot is true, shows full snapshot instead. + */ +async function executeWithChanges( + action: () => Promise, includeSnapshot: boolean | undefined, response: {appendResponseLine(v: string): void}, -): Promise { +): Promise { if (includeSnapshot) { + // User explicitly wants full snapshot — skip diff, just execute and show snapshot + const result = await action(); const {formatted} = await fetchAXTree(false); response.appendResponseLine('## Latest page snapshot'); response.appendResponseLine(formatted); + return result; } + + // Default: show diff + const {result, summary} = await executeWithDiff(action, 1500); + response.appendResponseLine('## Changes detected'); + response.appendResponseLine(summary); + return result; } export const click = defineTool({ @@ -59,14 +74,17 @@ export const click = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - const {uid, dblClick} = request.params; - await clickElement(uid, dblClick ? 2 : 1); + const {uid, dblClick, includeSnapshot} = request.params; + await executeWithChanges( + async () => clickElement(uid, dblClick ? 2 : 1), + includeSnapshot, + response, + ); response.appendResponseLine( dblClick - ? 'Successfully double clicked on the element' - : 'Successfully clicked on the element', + ? 'Double clicked on the element' + : 'Clicked on the element', ); - await maybeSnapshot(request.params.includeSnapshot, response); }, }); @@ -86,14 +104,17 @@ export const clickAt = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - const {x, y, dblClick} = request.params; - await clickAtCoords(x, y, dblClick ? 2 : 1); + const {x, y, dblClick, includeSnapshot} = request.params; + await executeWithChanges( + async () => clickAtCoords(x, y, dblClick ? 2 : 1), + includeSnapshot, + response, + ); response.appendResponseLine( dblClick - ? 'Successfully double clicked at the coordinates' - : 'Successfully clicked at the coordinates', + ? 'Double clicked at the coordinates' + : 'Clicked at the coordinates', ); - await maybeSnapshot(request.params.includeSnapshot, response); }, }); @@ -115,9 +136,13 @@ export const hover = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - await hoverElement(request.params.uid); - response.appendResponseLine('Successfully hovered over the element'); - await maybeSnapshot(request.params.includeSnapshot, response); + const {uid, includeSnapshot} = request.params; + await executeWithChanges( + async () => hoverElement(uid), + includeSnapshot, + response, + ); + response.appendResponseLine('Hovered over the element'); }, }); @@ -140,9 +165,13 @@ export const fill = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - await fillElement(request.params.uid, request.params.value); - response.appendResponseLine('Successfully filled out the element'); - await maybeSnapshot(request.params.includeSnapshot, response); + const {uid, value, includeSnapshot} = request.params; + await executeWithChanges( + async () => fillElement(uid, value), + includeSnapshot, + response, + ); + response.appendResponseLine('Filled out the element'); }, }); @@ -161,9 +190,13 @@ export const drag = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - await dragElement(request.params.from_uid, request.params.to_uid); - response.appendResponseLine('Successfully dragged an element'); - await maybeSnapshot(request.params.includeSnapshot, response); + const {from_uid, to_uid, includeSnapshot} = request.params; + await executeWithChanges( + async () => dragElement(from_uid, to_uid), + includeSnapshot, + response, + ); + response.appendResponseLine('Dragged the element'); }, }); @@ -188,11 +221,17 @@ export const fillForm = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - for (const element of request.params.elements) { - await fillElement(element.uid, element.value); - } - response.appendResponseLine('Successfully filled out the form'); - await maybeSnapshot(request.params.includeSnapshot, response); + const {elements, includeSnapshot} = request.params; + await executeWithChanges( + async () => { + for (const element of elements) { + await fillElement(element.uid, element.value); + } + }, + includeSnapshot, + response, + ); + response.appendResponseLine('Filled out the form'); }, }); @@ -215,16 +254,21 @@ export const uploadFile = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - const {uid, filePath} = request.params; - // Use DOM.setFileInputFiles for file input elements - const backendNodeId = (await import('../ax-tree.js')).getBackendNodeId(uid); - await sendCdp('DOM.enable'); - await sendCdp('DOM.setFileInputFiles', { - files: [filePath], - backendNodeId, - }); + const {uid, filePath, includeSnapshot} = request.params; + await executeWithChanges( + async () => { + // Use DOM.setFileInputFiles for file input elements + const backendNodeId = (await import('../ax-tree.js')).getBackendNodeId(uid); + await sendCdp('DOM.enable'); + await sendCdp('DOM.setFileInputFiles', { + files: [filePath], + backendNodeId, + }); + }, + includeSnapshot, + response, + ); response.appendResponseLine(`File uploaded from ${filePath}.`); - await maybeSnapshot(request.params.includeSnapshot, response); }, }); @@ -246,10 +290,12 @@ export const pressKeyTool = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response) => { - await pressKey(request.params.key); - response.appendResponseLine( - `Successfully pressed key: ${request.params.key}`, + const {key, includeSnapshot} = request.params; + await executeWithChanges( + async () => pressKey(key), + includeSnapshot, + response, ); - await maybeSnapshot(request.params.includeSnapshot, response); + response.appendResponseLine(`Pressed key: ${key}`); }, }); diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 8157db29d..3b2309667 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -88,12 +88,13 @@ export const screenshot = defineTool({ } else if (data.length >= 2_000_000) { const tmpPath = `screenshot-${Date.now()}.${format}`; writeFileSync(tmpPath, data); - response.appendResponseLine(`Saved screenshot to ${tmpPath}.`); + response.appendResponseLine(`Screenshot too large for inline (${(data.length / 1024 / 1024).toFixed(1)}MB). Saved to ${tmpPath}.`); } else { response.attachImage({ mimeType: `image/${format}`, data: data.toString('base64'), }); + response.appendResponseLine('Screenshot attached inline.'); } }, }); From bef7a17746e667a1c185747b5a8eb64b359380f9 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 04:41:10 -0600 Subject: [PATCH 007/127] feat: add notification and modal interception layer to handle blocking UI before tool execution --- src/browser.ts | 3 + src/main.ts | 32 +++ src/notification-gate.ts | 433 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 468 insertions(+) create mode 100644 src/notification-gate.ts diff --git a/src/browser.ts b/src/browser.ts index 50fa73d16..889d805d8 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -703,6 +703,9 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { 'update.showReleaseNotes': false, 'extensions.ignoreRecommendations': true, 'telemetry.telemetryLevel': 'off', + // Use DOM-based dialogs instead of native OS dialogs + // This allows CDP to interact with Save/Confirm dialogs + 'window.dialogStyle': 'custom', }, null, 2), ); diff --git a/src/main.ts b/src/main.ts index 9dd972d39..2e1aa83b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import process from 'node:process'; import {fileURLToPath} from 'node:url'; import {ensureVSCodeConnected, getConnectionGeneration, getPuppeteerBrowser, teardownSync} from './browser.js'; +import {checkForBlockingUI} from './notification-gate.js'; import {parseArguments} from './cli.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; @@ -258,6 +259,29 @@ function registerTool(tool: ToolDefinition): void { // Always ensure VS Code connection (CDP + bridge) await ensureConnection(); + // Check for blocking modals/notifications before tool execution + // BLOCKING modals (e.g., "Save file?") → STOP tool, return modal info + // NON-BLOCKING notifications (toasts) → Prepend banner, let tool proceed + // + // EXCEPTION: Input tools (press_key, click, click_at, hover, drag) BYPASS the gate + // when there's a blocking UI. This allows the user to dismiss the dialog. + // Without this, there would be no way to interact with blocking dialogs via MCP. + const inputTools = ['press_key', 'click', 'click_at', 'hover', 'drag', 'fill']; + const isInputTool = inputTools.includes(tool.name); + + const uiCheck = await checkForBlockingUI(); + if (uiCheck.blocked && !isInputTool) { + // Blocked and NOT an input tool - return blocking message + const content: Array<{type: string; text?: string}> = []; + if (uiCheck.notificationBanner) { + content.push({type: 'text', text: uiCheck.notificationBanner}); + } + content.push({type: 'text', text: uiCheck.blockingMessage!}); + return {content}; + } + // For input tools when blocked: still prepend banner but let tool execute + const notificationBanner = uiCheck.notificationBanner; + // Diagnostic and directCdp tools bypass McpContext — they use sendCdp/bridgeExec directly. // Non-diagnostic tools need full McpContext (Phase B will refactor this). const isDiagnostic = tool.annotations.conditions?.includes('devDiagnostic'); @@ -278,6 +302,10 @@ function registerTool(tool: ToolDefinition): void { // Diagnostic/directCdp tools return content directly without McpResponse.handle() if (bypassContext) { const content: Array<{type: string; text?: string; data?: string; mimeType?: string}> = []; + // Prepend notification banner if present + if (notificationBanner) { + content.push({type: 'text', text: notificationBanner}); + } for (const line of response.responseLines) { content.push({type: 'text', text: line}); } @@ -294,6 +322,10 @@ function registerTool(tool: ToolDefinition): void { tool.name, ctx, ); + // Prepend notification banner for non-bypass tools + if (notificationBanner) { + (content as Array<{type: string; text?: string}>).unshift({type: 'text', text: notificationBanner}); + } return {content, structuredContent}; }; diff --git a/src/notification-gate.ts b/src/notification-gate.ts new file mode 100644 index 000000000..292bf032f --- /dev/null +++ b/src/notification-gate.ts @@ -0,0 +1,433 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Notification and Modal Interception Layer + * + * Detects pending VS Code notifications and modals before tool execution: + * - BLOCKING modals (e.g., "Save file?" dialogs) → STOP tool execution, return modal info + * - NON-BLOCKING notifications (toasts) → Prepend banner to output, let tool proceed + * + * This mirrors human behavior: a blocking modal stops you until addressed, + * while toast notifications are informational and don't prevent work. + */ + +import {sendCdp} from './browser.js'; +import {logger} from './logger.js'; + +// ── Types ── + +export interface UIButton { + label: string; + index: number; +} + +export interface PendingUIElement { + type: 'modal' | 'notification' | 'dialog'; + severity: 'info' | 'warning' | 'error' | 'blocking'; + message: string; + source?: string; + buttons: UIButton[]; + isBlocking: boolean; + /** For notifications: unique ID to track dismissal */ + elementId?: string; +} + +export interface NotificationCheckResult { + /** Blocking modals that prevent tool execution */ + blocking: PendingUIElement[]; + /** Non-blocking notifications to show in output */ + nonBlocking: PendingUIElement[]; + /** True if there are any blocking elements */ + hasBlocking: boolean; + /** True if there are any notifications at all */ + hasAny: boolean; +} + +// ── Detection Logic ── + +/** + * Query the VS Code DOM for pending modals and notifications via CDP. + * Also detects native OS dialogs by checking window focus state. + */ +export async function checkPendingNotifications(): Promise { + const result: NotificationCheckResult = { + blocking: [], + nonBlocking: [], + hasBlocking: false, + hasAny: false, + }; + + try { + // Run detection script in the VS Code renderer + const evalResult = await sendCdp('Runtime.evaluate', { + expression: `(function() { + const result = { + modals: [], + notifications: [], + quickInput: null, + nativeDialog: null, + }; + + // 0. Check for native OS dialogs by detecting focus loss + // When a native dialog opens, the VS Code window loses focus + // Note: This heuristic may have false positives if user clicks away + if (!document.hasFocus()) { + // Additional check: look for blockModalDialogRunning flag (internal VS Code state) + // This is set when VS Code is waiting for a native dialog response + try { + const windowService = window.require && window.require('vs/base/browser/dom').getActiveWindow(); + // If we can't get the window service, check if any input is disabled + const inputs = document.querySelectorAll('input:not([disabled]), button:not([disabled])'); + const allDisabled = inputs.length === 0; + if (allDisabled || !document.hasFocus()) { + result.nativeDialog = { + type: 'modal', + severity: 'blocking', + message: 'Native OS dialog is open (Save/Confirm dialog). Please respond to the dialog in the VS Code window.', + buttons: [], + isBlocking: true, + }; + } + } catch (e) { + // Fall back to just focus check + result.nativeDialog = { + type: 'modal', + severity: 'blocking', + message: 'A native OS dialog may be open (the VS Code window does not have focus). Check for Save/Confirm dialogs in the VS Code window.', + buttons: [], + isBlocking: true, + }; + } + } + + // 1. Check for Monaco dialogs (blocking modals) + // These are used for "Save file?", confirmations, etc. + const dialogs = document.querySelectorAll('.monaco-dialog-box'); + for (const dialog of dialogs) { + if (dialog.offsetParent === null) continue; // Skip hidden dialogs + + const messageEl = dialog.querySelector('.dialog-message-text, .dialog-message'); + const message = messageEl?.textContent?.trim() || ''; + + const buttons = []; + const buttonEls = dialog.querySelectorAll('.dialog-buttons .monaco-button, .dialog-buttons button'); + for (let i = 0; i < buttonEls.length; i++) { + buttons.push({ + label: buttonEls[i].textContent?.trim() || '', + index: i, + }); + } + + // Determine severity from dialog icon (codicon class) + const iconEl = dialog.querySelector('.dialog-icon .codicon, .dialog-icon'); + let severity = 'blocking'; + if (iconEl) { + const classes = iconEl.className || ''; + if (classes.includes('codicon-warning')) severity = 'warning'; + else if (classes.includes('codicon-error')) severity = 'error'; + else if (classes.includes('codicon-info')) severity = 'info'; + } + + result.modals.push({ + type: 'modal', + severity, + message, + buttons, + isBlocking: true, + }); + } + + // 2. Check for Quick Input dialogs (command palette, quick picks) + // CRITICAL: VS Code uses display:none CSS, NOT .hidden class + const quickInputs = document.querySelectorAll('.quick-input-widget'); + for (const quickInput of quickInputs) { + const style = window.getComputedStyle(quickInput); + const isVisible = style.display !== 'none' && quickInput.offsetHeight > 0; + if (!isVisible) continue; + + const titleEl = quickInput.querySelector('.quick-input-title-label, .quick-input-title'); + const inputEl = quickInput.querySelector('.quick-input-box input, input'); + const title = titleEl?.textContent?.trim() || ''; + const placeholder = inputEl?.getAttribute('placeholder') || ''; + + result.quickInput = { + type: 'dialog', + severity: 'info', + message: title || placeholder || 'Quick input is open', + buttons: [{ label: 'Escape to close', index: 0 }], + isBlocking: true, // Blocks keyboard input + }; + } + + // 3. Check for notification toasts (non-blocking) + const toastContainer = document.querySelector('.notifications-toasts'); + if (toastContainer) { + const toasts = toastContainer.querySelectorAll('.notification-toast'); + for (const toast of toasts) { + // Check actual visibility, not just offsetParent + const style = window.getComputedStyle(toast); + if (style.display === 'none' || toast.offsetHeight === 0) continue; + + // FIXED: Correct selector for notification message + const messageEl = toast.querySelector('.notification-list-item-message, .notification-message'); + const message = messageEl?.textContent?.trim() || ''; + + const sourceEl = toast.querySelector('.notification-list-item-source-label, .notification-source'); + const source = sourceEl?.textContent?.trim() || ''; + + const buttons = []; + // FIXED: Correct selector for action buttons + const actionEls = toast.querySelectorAll('.notification-list-item-buttons-container button, .notification-actions-primary .monaco-button'); + for (let i = 0; i < actionEls.length; i++) { + const label = actionEls[i].textContent?.trim() || actionEls[i].getAttribute('title') || ''; + if (label) buttons.push({ label, index: i }); + } + + // FIXED: Severity detection - look for codicon classes anywhere in toast + const iconEl = toast.querySelector('.codicon'); + let severity = 'info'; + if (iconEl) { + if (iconEl.classList.contains('codicon-warning')) severity = 'warning'; + else if (iconEl.classList.contains('codicon-error')) severity = 'error'; + } + + // Generate element ID for tracking + const elementId = 'toast-' + message.substring(0, 50).replace(/\\W+/g, '-'); + + result.notifications.push({ + type: 'notification', + severity, + message, + source, + buttons, + isBlocking: false, + elementId, + }); + } + } + + // 4. Check for notification center badge (collapsed notifications) + const notificationBadge = document.querySelector('.notifications-center .notification-actions-container .monaco-count-badge'); + if (notificationBadge) { + const count = parseInt(notificationBadge.textContent || '0', 10); + if (count > 0) { + result.notifications.push({ + type: 'notification', + severity: 'info', + message: count + ' notification(s) in notification center', + buttons: [], + isBlocking: false, + elementId: 'notification-center-count', + }); + } + } + + // 5. Check for editor dirty indicator with unsaved changes modal + // (This is shown when you try to close a dirty file) + const dirtyModal = document.querySelector('.monaco-dialog-box .dialog-message-text'); + if (dirtyModal && dirtyModal.textContent?.includes("want to save")) { + // Already captured by modals above, but ensure it's marked as blocking + } + + return JSON.stringify(result); + })()`, + returnByValue: true, + }); + + if (evalResult?.result?.value) { + const detected = JSON.parse(evalResult.result.value); + + // Process native OS dialog (highest priority - fully blocks UI) + if (detected.nativeDialog) { + result.blocking.push(detected.nativeDialog as PendingUIElement); + } + + // Process blocking modals + for (const modal of detected.modals) { + result.blocking.push(modal as PendingUIElement); + } + + // Process quick input (blocking) + if (detected.quickInput) { + result.blocking.push(detected.quickInput as PendingUIElement); + } + + // Process non-blocking notifications + for (const notification of detected.notifications) { + result.nonBlocking.push(notification as PendingUIElement); + } + } + + result.hasBlocking = result.blocking.length > 0; + result.hasAny = result.blocking.length > 0 || result.nonBlocking.length > 0; + + } catch (error) { + logger(`Notification check failed: ${error}`); + // Don't throw — treat as no notifications + } + + return result; +} + +// ── Formatting ── + +/** + * Format a blocking modal as an error message for tool output. + */ +export function formatBlockingModal(modal: PendingUIElement): string { + const lines: string[] = []; + lines.push(`## ⛔ BLOCKED: ${modal.type === 'modal' ? 'Modal Dialog' : 'Dialog'} Requires Attention`); + lines.push(''); + lines.push(`**Message:** ${modal.message}`); + if (modal.source) { + lines.push(`**Source:** ${modal.source}`); + } + lines.push(''); + if (modal.buttons.length > 0) { + lines.push('**Available actions:**'); + for (const btn of modal.buttons) { + lines.push(` - "${btn.label}"`); + } + lines.push(''); + lines.push('Use `click` on one of the dialog buttons, or `press_key` with "Escape" to dismiss.'); + } else { + lines.push('Press Escape or click outside to dismiss this dialog.'); + } + return lines.join('\n'); +} + +/** + * Format non-blocking notifications as a banner for tool output. + */ +export function formatNotificationBanner(notifications: PendingUIElement[]): string { + if (notifications.length === 0) return ''; + + const lines: string[] = []; + lines.push('## ℹ️ Pending Notifications'); + lines.push(''); + + for (const notif of notifications) { + const icon = notif.severity === 'error' ? '🔴' : + notif.severity === 'warning' ? '🟡' : '🔵'; + let line = `${icon} ${notif.message}`; + if (notif.source) { + line += ` (${notif.source})`; + } + if (notif.buttons.length > 0) { + line += ` [Actions: ${notif.buttons.map(b => b.label).join(', ')}]`; + } + lines.push(line); + } + + lines.push(''); + lines.push('---'); + lines.push(''); + + return lines.join('\n'); +} + +// ── Tool Integration ── + +/** + * Check for blocking modals before tool execution. + * Returns null if no blocking elements, or formatted error message if blocked. + */ +export async function checkForBlockingUI(): Promise<{ + blocked: boolean; + blockingMessage?: string; + notificationBanner?: string; +}> { + const check = await checkPendingNotifications(); + + if (check.hasBlocking) { + // Tool execution is blocked + const modal = check.blocking[0]; // Show the first blocking element + return { + blocked: true, + blockingMessage: formatBlockingModal(modal), + notificationBanner: formatNotificationBanner(check.nonBlocking), + }; + } + + // Not blocked, but may have notifications to show + return { + blocked: false, + notificationBanner: check.nonBlocking.length > 0 + ? formatNotificationBanner(check.nonBlocking) + : undefined, + }; +} + +/** + * Click a button in a modal dialog by its label. + */ +export async function clickModalButton(buttonLabel: string): Promise { + try { + const result = await sendCdp('Runtime.evaluate', { + expression: `(function() { + const buttons = document.querySelectorAll('.monaco-dialog-box .dialog-buttons .monaco-button'); + for (const btn of buttons) { + if (btn.textContent?.trim() === '${buttonLabel.replace(/'/g, "\\'")}') { + btn.click(); + return true; + } + } + return false; + })()`, + returnByValue: true, + }); + return result?.result?.value === true; + } catch { + return false; + } +} + +/** + * Click a button in a notification toast by its label. + */ +export async function clickNotificationButton(buttonLabel: string): Promise { + try { + const result = await sendCdp('Runtime.evaluate', { + expression: `(function() { + const buttons = document.querySelectorAll('.notification-toast .notification-actions-primary .monaco-button'); + for (const btn of buttons) { + if (btn.textContent?.trim() === '${buttonLabel.replace(/'/g, "\\'")}') { + btn.click(); + return true; + } + } + return false; + })()`, + returnByValue: true, + }); + return result?.result?.value === true; + } catch { + return false; + } +} + +/** + * Dismiss the topmost notification toast. + */ +export async function dismissTopNotification(): Promise { + try { + const result = await sendCdp('Runtime.evaluate', { + expression: `(function() { + const closeBtn = document.querySelector('.notification-toast .codicon-notifications-clear'); + if (closeBtn) { + closeBtn.click(); + return true; + } + return false; + })()`, + returnByValue: true, + }); + return result?.result?.value === true; + } catch { + return false; + } +} From 14ff42119db340b79c7b7a982c9aa1f2391b11f2 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 05:14:42 -0600 Subject: [PATCH 008/127] feat: implement CDP event subscriptions and enhance console/network tools for improved tracking --- src/browser.ts | 5 + src/cdp-events.ts | 412 +++++++++++++++++++++++++++++++++++++++ src/notification-gate.ts | 42 +--- src/tools/console.ts | 67 +++++-- src/tools/network.ts | 114 +++++++---- src/tools/performance.ts | 151 ++++++++------ 6 files changed, 651 insertions(+), 140 deletions(-) create mode 100644 src/cdp-events.ts diff --git a/src/browser.ts b/src/browser.ts index 889d805d8..8ca94e3ee 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -33,6 +33,7 @@ import { bridgeExec, bridgeAttachDebugger, } from './bridge-client.js'; +import {initCdpEventSubscriptions} from './cdp-events.js'; import {logger} from './logger.js'; import type {Browser, ConnectionTransport} from './third_party/index.js'; import {puppeteer} from './third_party/index.js'; @@ -798,6 +799,10 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { // 9. Enable CDP domains and wait for readiness await sendCdp('Runtime.enable', {}, cdpWs); await sendCdp('Page.enable', {}, cdpWs); + + // 9a. Initialize CDP event subscriptions for console/network tracking + await initCdpEventSubscriptions(); + await waitForWorkbenchReady(cdpWs); // 9b. Create Puppeteer Browser via ElectronTransport. diff --git a/src/cdp-events.ts b/src/cdp-events.ts new file mode 100644 index 000000000..67758a1d6 --- /dev/null +++ b/src/cdp-events.ts @@ -0,0 +1,412 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * CDP Event Collector — Raw CDP event subscription and storage. + * + * This module provides an alternative to PageCollector for environments + * where we use a raw CDP WebSocket connection (e.g., VS Code Extension Host) + * instead of Puppeteer's browser-level connection. + * + * It subscribes to CDP events for: + * - Console messages (Runtime.consoleAPICalled) + * - Network requests (Network.requestWillBeSent, responseReceived, loadingFinished, loadingFailed) + * - Tracing (Tracing.dataCollected, Tracing.tracingComplete) + */ + +import WebSocket from 'ws'; +import {sendCdp, getCdpWebSocket, getConnectionGeneration} from './browser.js'; +import {logger} from './logger.js'; + +// ── Types ─────────────────────────────────────────────── + +export interface ConsoleMessage { + id: number; + type: string; + text: string; + args: Array<{type: string; value?: unknown; description?: string}>; + timestamp: number; + stackTrace?: Array<{ + functionName: string; + url: string; + lineNumber: number; + columnNumber: number; + }>; +} + +export interface NetworkRequest { + id: number; + requestId: string; + url: string; + method: string; + resourceType: string; + timestamp: number; + status?: number; + statusText?: string; + mimeType?: string; + responseHeaders?: Record; + failed?: boolean; + errorText?: string; + responseBody?: string; + requestBody?: string; +} + +export interface TraceData { + chunks: unknown[]; + complete: boolean; + filePath?: string; +} + +// ── Storage ───────────────────────────────────────────── + +let consoleMessages: ConsoleMessage[] = []; +let networkRequests: Map = new Map(); +let traceData: TraceData = {chunks: [], complete: false}; + +let consoleIdCounter = 1; +let networkIdCounter = 1; +let subscribedGeneration = -1; +let eventListenerCleanup: (() => void) | undefined; + +// ── Event Handlers ────────────────────────────────────── + +function handleConsoleAPICalled(params: { + type: string; + args: Array<{type: string; value?: unknown; description?: string}>; + timestamp: number; + stackTrace?: {callFrames: Array<{functionName: string; url: string; lineNumber: number; columnNumber: number}>}; +}): void { + const textParts: string[] = []; + for (const arg of params.args) { + if (arg.value !== undefined) { + textParts.push(String(arg.value)); + } else if (arg.description) { + textParts.push(arg.description); + } else { + textParts.push(`[${arg.type}]`); + } + } + + const message: ConsoleMessage = { + id: consoleIdCounter++, + type: params.type, + text: textParts.join(' '), + args: params.args, + timestamp: params.timestamp, + }; + + if (params.stackTrace?.callFrames?.length) { + message.stackTrace = params.stackTrace.callFrames.map(cf => ({ + functionName: cf.functionName, + url: cf.url, + lineNumber: cf.lineNumber, + columnNumber: cf.columnNumber, + })); + } + + consoleMessages.push(message); +} + +function handleRequestWillBeSent(params: { + requestId: string; + request: {url: string; method: string; postData?: string}; + type?: string; + timestamp: number; +}): void { + const request: NetworkRequest = { + id: networkIdCounter++, + requestId: params.requestId, + url: params.request.url, + method: params.request.method, + resourceType: params.type ?? 'other', + timestamp: params.timestamp, + }; + + if (params.request.postData) { + request.requestBody = params.request.postData; + } + + networkRequests.set(params.requestId, request); +} + +function handleResponseReceived(params: { + requestId: string; + response: { + status: number; + statusText: string; + mimeType: string; + headers: Record; + }; + type?: string; +}): void { + const request = networkRequests.get(params.requestId); + if (request) { + request.status = params.response.status; + request.statusText = params.response.statusText; + request.mimeType = params.response.mimeType; + request.responseHeaders = params.response.headers; + if (params.type) { + request.resourceType = params.type; + } + } +} + +function handleLoadingFinished(params: {requestId: string}): void { + // Request completed successfully - nothing to update, already marked as not failed +} + +function handleLoadingFailed(params: { + requestId: string; + errorText: string; +}): void { + const request = networkRequests.get(params.requestId); + if (request) { + request.failed = true; + request.errorText = params.errorText; + } +} + +function handleTracingDataCollected(params: {value: unknown[]}): void { + traceData.chunks.push(...params.value); +} + +function handleTracingComplete(): void { + traceData.complete = true; +} + +// ── CDP Message Router ────────────────────────────────── + +function routeCdpEvent(data: {method?: string; params?: unknown}): void { + if (!data.method) return; + + switch (data.method) { + case 'Runtime.consoleAPICalled': + handleConsoleAPICalled(data.params as Parameters[0]); + break; + case 'Network.requestWillBeSent': + handleRequestWillBeSent(data.params as Parameters[0]); + break; + case 'Network.responseReceived': + handleResponseReceived(data.params as Parameters[0]); + break; + case 'Network.loadingFinished': + handleLoadingFinished(data.params as Parameters[0]); + break; + case 'Network.loadingFailed': + handleLoadingFailed(data.params as Parameters[0]); + break; + case 'Tracing.dataCollected': + handleTracingDataCollected(data.params as Parameters[0]); + break; + case 'Tracing.tracingComplete': + handleTracingComplete(); + break; + } +} + +// ── Public API ────────────────────────────────────────── + +/** + * Initialize CDP event subscriptions on the current WebSocket connection. + * Safe to call multiple times - only subscribes once per connection generation. + */ +export async function initCdpEventSubscriptions(): Promise { + const ws = getCdpWebSocket(); + if (!ws || ws.readyState !== WebSocket.OPEN) { + logger('CDP WebSocket not available for event subscriptions'); + return; + } + + const currentGeneration = getConnectionGeneration(); + if (subscribedGeneration === currentGeneration) { + // Already subscribed to this connection + return; + } + + // Clean up previous listeners + if (eventListenerCleanup) { + eventListenerCleanup(); + eventListenerCleanup = undefined; + } + + // Clear stored data for new connection + clearAllData(); + subscribedGeneration = currentGeneration; + + // Set up event listener + const messageHandler = (evt: WebSocket.MessageEvent) => { + try { + const raw = typeof evt.data === 'string' ? evt.data : evt.data.toString(); + const data = JSON.parse(raw); + routeCdpEvent(data); + } catch { + // Ignore parse errors + } + }; + + ws.addEventListener('message', messageHandler); + eventListenerCleanup = () => ws.removeEventListener('message', messageHandler); + + // Enable required domains (Runtime is usually already enabled) + try { + // Network domain - enable for request/response tracking + await sendCdp('Network.enable', {}); + logger('Network domain enabled for CDP event collection'); + } catch (err) { + logger('Warning: Failed to enable Network domain:', err); + } + + logger('CDP event subscriptions initialized'); +} + +/** + * Clear all stored data. Called on navigation or reconnection. + */ +export function clearAllData(): void { + consoleMessages = []; + networkRequests = new Map(); + traceData = {chunks: [], complete: false}; + consoleIdCounter = 1; + networkIdCounter = 1; +} + +/** + * Get all console messages, optionally filtered by type. + */ +export function getConsoleMessages(options?: { + types?: string[]; + pageSize?: number; + pageIdx?: number; +}): {messages: ConsoleMessage[]; total: number} { + let filtered = consoleMessages; + + if (options?.types?.length) { + const typeSet = new Set(options.types); + filtered = filtered.filter(m => typeSet.has(m.type)); + } + + const total = filtered.length; + + if (options?.pageSize !== undefined) { + const pageIdx = options.pageIdx ?? 0; + const start = pageIdx * options.pageSize; + filtered = filtered.slice(start, start + options.pageSize); + } + + return {messages: filtered, total}; +} + +/** + * Get a specific console message by ID. + */ +export function getConsoleMessageById(id: number): ConsoleMessage | undefined { + return consoleMessages.find(m => m.id === id); +} + +/** + * Get all network requests, optionally filtered by resource type. + */ +export function getNetworkRequests(options?: { + resourceTypes?: string[]; + pageSize?: number; + pageIdx?: number; +}): {requests: NetworkRequest[]; total: number} { + let requests = Array.from(networkRequests.values()); + + if (options?.resourceTypes?.length) { + const typeSet = new Set(options.resourceTypes.map(t => t.toLowerCase())); + requests = requests.filter(r => typeSet.has(r.resourceType.toLowerCase())); + } + + const total = requests.length; + + if (options?.pageSize !== undefined) { + const pageIdx = options.pageIdx ?? 0; + const start = pageIdx * options.pageSize; + requests = requests.slice(start, start + options.pageSize); + } + + return {requests, total}; +} + +/** + * Get a specific network request by ID. + */ +export function getNetworkRequestById(id: number): NetworkRequest | undefined { + for (const req of networkRequests.values()) { + if (req.id === id) { + return req; + } + } + return undefined; +} + +/** + * Get response body for a network request. + */ +export async function getNetworkResponseBody(requestId: string): Promise { + try { + const result = await sendCdp('Network.getResponseBody', {requestId}); + if (result.base64Encoded) { + return Buffer.from(result.body, 'base64').toString('utf-8'); + } + return result.body; + } catch { + return undefined; + } +} + +// ── Tracing API ───────────────────────────────────────── + +/** + * Start a performance trace. + */ +export async function startTrace(options?: { + categories?: string[]; +}): Promise { + traceData = {chunks: [], complete: false}; + + const categories = options?.categories ?? [ + '-*', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'disabled-by-default-devtools.timeline.stack', + 'v8.execute', + 'blink.console', + 'blink.user_timing', + ]; + + await sendCdp('Tracing.start', { + categories: categories.join(','), + transferMode: 'ReturnAsStream', + }); + + logger('Tracing started'); +} + +/** + * Stop the performance trace and return the collected data. + */ +export async function stopTrace(): Promise { + await sendCdp('Tracing.end', {}); + + // Wait for tracingComplete event or timeout + const startTime = Date.now(); + while (!traceData.complete && Date.now() - startTime < 30000) { + await new Promise(r => setTimeout(r, 100)); + } + + logger(`Tracing stopped, collected ${traceData.chunks.length} chunks`); + return traceData.chunks; +} + +/** + * Get current trace data (for insight analysis). + */ +export function getTraceData(): TraceData { + return traceData; +} diff --git a/src/notification-gate.ts b/src/notification-gate.ts index 292bf032f..e91ccc945 100644 --- a/src/notification-gate.ts +++ b/src/notification-gate.ts @@ -72,37 +72,11 @@ export async function checkPendingNotifications(): Promise { - response.setIncludeConsoleData(true, { + const {messages, total} = getConsoleMessages({ + types: request.params.types, pageSize: request.params.pageSize, pageIdx: request.params.pageIdx, - types: request.params.types, - includePreservedMessages: request.params.includePreservedMessages, }); + + if (messages.length === 0) { + response.appendResponseLine('No console messages found.'); + return; + } + + response.appendResponseLine(`Console messages (${messages.length} of ${total} total):\n`); + + for (const msg of messages) { + const typeTag = `[${msg.type.toUpperCase()}]`; + response.appendResponseLine(`msgid=${msg.id} ${typeTag} ${msg.text}`); + if (msg.stackTrace?.length) { + const first = msg.stackTrace[0]; + response.appendResponseLine(` at ${first.functionName || '(anonymous)'} (${first.url}:${first.lineNumber + 1}:${first.columnNumber + 1})`); + } + } }, }); @@ -94,6 +106,7 @@ export const getConsoleMessage = defineTool({ annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: true, + conditions: ['directCdp'], }, schema: { msgid: zod @@ -103,6 +116,36 @@ export const getConsoleMessage = defineTool({ ), }, handler: async (request, response) => { - response.attachConsoleMessage(request.params.msgid); + const msg = getConsoleMessageById(request.params.msgid); + + if (!msg) { + response.appendResponseLine(`Console message with id ${request.params.msgid} not found.`); + return; + } + + response.appendResponseLine(`msgid=${msg.id}`); + response.appendResponseLine(`type=${msg.type}`); + response.appendResponseLine(`text=${msg.text}`); + response.appendResponseLine(`timestamp=${new Date(msg.timestamp).toISOString()}`); + + if (msg.args.length > 0) { + response.appendResponseLine('\nArguments:'); + for (const arg of msg.args) { + if (arg.value !== undefined) { + response.appendResponseLine(` [${arg.type}] ${JSON.stringify(arg.value)}`); + } else if (arg.description) { + response.appendResponseLine(` [${arg.type}] ${arg.description}`); + } else { + response.appendResponseLine(` [${arg.type}]`); + } + } + } + + if (msg.stackTrace?.length) { + response.appendResponseLine('\nStack trace:'); + for (const frame of msg.stackTrace) { + response.appendResponseLine(` at ${frame.functionName || '(anonymous)'} (${frame.url}:${frame.lineNumber + 1}:${frame.columnNumber + 1})`); + } + } }, }); diff --git a/src/tools/network.ts b/src/tools/network.ts index 23526c6be..80d08ca55 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {getNetworkRequests, getNetworkRequestById, getNetworkResponseBody} from '../cdp-events.js'; import {zod} from '../third_party/index.js'; -import type {ResourceType} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; -const FILTERABLE_RESOURCE_TYPES: readonly [ResourceType, ...ResourceType[]] = [ +const FILTERABLE_RESOURCE_TYPES: readonly [string, ...string[]] = [ 'document', 'stylesheet', 'image', @@ -39,6 +39,7 @@ export const listNetworkRequests = defineTool({ annotations: { category: ToolCategory.NETWORK, readOnlyHint: true, + conditions: ['directCdp'], }, schema: { pageSize: zod @@ -71,19 +72,27 @@ export const listNetworkRequests = defineTool({ 'Set to true to return the preserved requests over the last 3 navigations.', ), }, - handler: async (request, response, context) => { - const data = await context.getDevToolsData(); - response.attachDevToolsData(data); - const reqid = data?.cdpRequestId - ? context.resolveCdpRequestId(data.cdpRequestId) - : undefined; - response.setIncludeNetworkRequests(true, { + handler: async (request, response) => { + const {requests, total} = getNetworkRequests({ + resourceTypes: request.params.resourceTypes, pageSize: request.params.pageSize, pageIdx: request.params.pageIdx, - resourceTypes: request.params.resourceTypes, - includePreservedRequests: request.params.includePreservedRequests, - networkRequestIdInDevToolsUI: reqid, }); + + if (requests.length === 0) { + response.appendResponseLine('No network requests found.'); + return; + } + + response.appendResponseLine(`Network requests (${requests.length} of ${total} total):\n`); + + for (const req of requests) { + const statusPart = req.status ? ` [${req.status}]` : ''; + const failedPart = req.failed ? ' [FAILED]' : ''; + response.appendResponseLine( + `reqid=${req.id} ${req.method} ${req.url}${statusPart}${failedPart} (${req.resourceType})` + ); + } }, }); @@ -94,6 +103,7 @@ export const getNetworkRequest = defineTool({ annotations: { category: ToolCategory.NETWORK, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { reqid: zod @@ -115,28 +125,66 @@ export const getNetworkRequest = defineTool({ 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.', ), }, - handler: async (request, response, context) => { - if (request.params.reqid) { - response.attachNetworkRequest(request.params.reqid, { - requestFilePath: request.params.requestFilePath, - responseFilePath: request.params.responseFilePath, - }); - } else { - const data = await context.getDevToolsData(); - response.attachDevToolsData(data); - const reqid = data?.cdpRequestId - ? context.resolveCdpRequestId(data.cdpRequestId) - : undefined; - if (reqid) { - response.attachNetworkRequest(reqid, { - requestFilePath: request.params.requestFilePath, - responseFilePath: request.params.responseFilePath, - }); - } else { - response.appendResponseLine( - `Nothing is currently selected in the DevTools Network panel.`, - ); + handler: async (request, response) => { + if (!request.params.reqid) { + response.appendResponseLine( + 'Please provide a reqid. Use list_network_requests to see available requests.', + ); + return; + } + + const req = getNetworkRequestById(request.params.reqid); + + if (!req) { + response.appendResponseLine(`Network request with id ${request.params.reqid} not found.`); + return; + } + + response.appendResponseLine(`reqid=${req.id}`); + response.appendResponseLine(`url=${req.url}`); + response.appendResponseLine(`method=${req.method}`); + response.appendResponseLine(`resourceType=${req.resourceType}`); + + if (req.status !== undefined) { + response.appendResponseLine(`status=${req.status} ${req.statusText || ''}`); + } + + if (req.mimeType) { + response.appendResponseLine(`mimeType=${req.mimeType}`); + } + + if (req.failed) { + response.appendResponseLine(`failed=true`); + response.appendResponseLine(`errorText=${req.errorText}`); + } + + if (req.responseHeaders && Object.keys(req.responseHeaders).length > 0) { + response.appendResponseLine('\nResponse Headers:'); + for (const [key, value] of Object.entries(req.responseHeaders)) { + response.appendResponseLine(` ${key}: ${value}`); + } + } + + if (req.requestBody) { + response.appendResponseLine('\nRequest Body:'); + response.appendResponseLine(req.requestBody); + } + + // Attempt to get response body + try { + const body = await getNetworkResponseBody(req.requestId); + if (body) { + response.appendResponseLine('\nResponse Body:'); + // Truncate if too long + const maxLen = 10000; + if (body.length > maxLen) { + response.appendResponseLine(body.substring(0, maxLen) + `\n... (truncated, ${body.length} total chars)`); + } else { + response.appendResponseLine(body); + } } + } catch { + // Response body may not be available } }, }); diff --git a/src/tools/performance.ts b/src/tools/performance.ts index edc8b1542..6f087a769 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -4,11 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +import fs from 'node:fs'; +import path from 'node:path'; import zlib from 'node:zlib'; +import {sendCdp} from '../browser.js'; +import {startTrace as cdpStartTrace, stopTrace as cdpStopTrace, getTraceData} from '../cdp-events.js'; import {logger} from '../logger.js'; import {zod, DevTools} from '../third_party/index.js'; -import type {Page} from '../third_party/index.js'; import type {InsightName, TraceResult} from '../trace-processing/parse.js'; import { parseRawTraceBuffer, @@ -19,6 +22,9 @@ import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; import {defineTool} from './ToolDefinition.js'; +// Module-level state for tracking trace status +let isRunningTrace = false; + const filePathSchema = zod .string() .optional() @@ -33,6 +39,7 @@ export const startTrace = defineTool({ annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { reload: zod @@ -48,67 +55,76 @@ export const startTrace = defineTool({ filePath: filePathSchema, }, handler: async (request, response, context) => { - if (context.isRunningPerformanceTrace()) { + if (isRunningTrace) { response.appendResponseLine( 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.', ); return; } - context.setIsRunningPerformanceTrace(true); + isRunningTrace = true; - const page = context.getSelectedPage(); - const pageUrlForTracing = page.url(); + try { + // Get current URL if we need to reload + let pageUrl: string | undefined; + if (request.params.reload) { + try { + const result = await sendCdp('Runtime.evaluate', { + expression: 'window.location.href', + returnByValue: true, + }); + pageUrl = result.result.value; - if (request.params.reload) { - // Before starting the recording, navigate to about:blank to clear out any state. - await page.goto('about:blank', { - waitUntil: ['networkidle0'], - }); - } + // Navigate to about:blank first to clear state + await sendCdp('Page.navigate', {url: 'about:blank'}); + await new Promise(r => setTimeout(r, 1000)); + } catch (err) { + logger('Error getting page URL:', err); + } + } - // Keep in sync with the categories arrays in: - // https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/panels/timeline/TimelineController.ts - // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/gather/gatherers/trace.js - const categories = [ - '-*', - 'blink.console', - 'blink.user_timing', - 'devtools.timeline', - 'disabled-by-default-devtools.screenshot', - 'disabled-by-default-devtools.timeline', - 'disabled-by-default-devtools.timeline.invalidationTracking', - 'disabled-by-default-devtools.timeline.frame', - 'disabled-by-default-devtools.timeline.stack', - 'disabled-by-default-v8.cpu_profiler', - 'disabled-by-default-v8.cpu_profiler.hires', - 'latencyInfo', - 'loading', - 'disabled-by-default-lighthouse', - 'v8.execute', - 'v8', - ]; - await page.tracing.start({ - categories, - }); + // Start tracing with CDP + const categories = [ + '-*', + 'blink.console', + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.screenshot', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.invalidationTracking', + 'disabled-by-default-devtools.timeline.frame', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + 'disabled-by-default-v8.cpu_profiler.hires', + 'latencyInfo', + 'loading', + 'disabled-by-default-lighthouse', + 'v8.execute', + 'v8', + ]; - if (request.params.reload) { - await page.goto(pageUrlForTracing, { - waitUntil: ['load'], - }); - } + await cdpStartTrace({categories}); - if (request.params.autoStop) { - await new Promise(resolve => setTimeout(resolve, 5_000)); - await stopTracingAndAppendOutput( - page, - response, - context, - request.params.filePath, - ); - } else { - response.appendResponseLine( - `The performance trace is being recorded. Use performance_stop_trace to stop it.`, - ); + if (request.params.reload && pageUrl) { + await sendCdp('Page.navigate', {url: pageUrl}); + // Wait for load + await new Promise(r => setTimeout(r, 3000)); + } + + if (request.params.autoStop) { + await new Promise(resolve => setTimeout(resolve, 5_000)); + await stopTracingAndAppendOutput( + response, + context, + request.params.filePath, + ); + } else { + response.appendResponseLine( + `The performance trace is being recorded. Use performance_stop_trace to stop it.`, + ); + } + } catch (err) { + isRunningTrace = false; + throw err; } }, }); @@ -121,17 +137,17 @@ export const stopTrace = defineTool({ annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: false, + conditions: ['directCdp'], }, schema: { filePath: filePathSchema, }, handler: async (request, response, context) => { - if (!context.isRunningPerformanceTrace()) { + if (!isRunningTrace) { + response.appendResponseLine('No performance trace is currently running.'); return; } - const page = context.getSelectedPage(); await stopTracingAndAppendOutput( - page, response, context, request.params.filePath, @@ -147,6 +163,7 @@ export const analyzeInsight = defineTool({ annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, + conditions: ['directCdp'], }, schema: { insightSetId: zod @@ -178,14 +195,19 @@ export const analyzeInsight = defineTool({ }); async function stopTracingAndAppendOutput( - page: Page, response: Response, context: Context, filePath?: string, ): Promise { try { - const traceEventsBuffer = await page.tracing.stop(); - if (filePath && traceEventsBuffer) { + const traceEvents = await cdpStopTrace(); + + // Convert trace events to JSON buffer + const traceData = {traceEvents}; + const traceJson = JSON.stringify(traceData); + const traceEventsBuffer = Buffer.from(traceJson, 'utf-8'); + + if (filePath) { let dataToWrite: Uint8Array = traceEventsBuffer; if (filePath.endsWith('.gz')) { dataToWrite = await new Promise((resolve, reject) => { @@ -198,13 +220,20 @@ async function stopTracingAndAppendOutput( }); }); } - const file = await context.saveFile(dataToWrite, filePath); + + // Write file directly since context.saveFile may not work + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(process.cwd(), filePath); + fs.writeFileSync(fullPath, dataToWrite); response.appendResponseLine( - `The raw trace data was saved to ${file.filename}.`, + `The raw trace data was saved to ${fullPath}.`, ); } + const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); + if (traceResultIsSuccess(result)) { if (context.isCruxEnabled()) { await populateCruxData(result); @@ -217,7 +246,7 @@ async function stopTracingAndAppendOutput( ); } } finally { - context.setIsRunningPerformanceTrace(false); + isRunningTrace = false; } } From 9d2ad4ec0ee1439c5488727339fe22c744dae879 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 05:26:42 -0600 Subject: [PATCH 009/127] feat: enhance initialization checks and cleanup processes for improved stability and data management --- src/browser.ts | 83 ++++++++++++++++++++++++++++++++++++++++- src/cdp-events.ts | 44 ++++++++++++++++++++++ src/tools/screenshot.ts | 2 +- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/browser.ts b/src/browser.ts index 8ca94e3ee..de968fb44 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -33,7 +33,7 @@ import { bridgeExec, bridgeAttachDebugger, } from './bridge-client.js'; -import {initCdpEventSubscriptions} from './cdp-events.js'; +import {initCdpEventSubscriptions, clearAllData} from './cdp-events.js'; import {logger} from './logger.js'; import type {Browser, ConnectionTransport} from './third_party/index.js'; import {puppeteer} from './third_party/index.js'; @@ -459,6 +459,8 @@ async function waitForWorkbenchReady( const state = JSON.parse(result.result.value); if (state.hasMonaco && state.readyState === 'complete') { logger('Workbench ready'); + // Now wait for Extension Host to finish initializing + await waitForExtensionHostReady(ws, Math.max(10_000, timeout - (Date.now() - start))); return; } logger( @@ -472,6 +474,81 @@ async function waitForWorkbenchReady( logger('Warning: timed out waiting for workbench — proceeding anyway'); } +/** + * Wait for Extension Host to finish initializing. + * Checks for: + * 1. No active progress indicators in the status bar + * 2. No "Activating Extensions" window state + * 3. Stable UI (no rapid DOM changes) + */ +async function waitForExtensionHostReady( + ws: WebSocket, + timeout = 8_000, +): Promise { + logger('Waiting for Extension Host to finish initializing...'); + const start = Date.now(); + let stableCount = 0; + const requiredStableChecks = 2; // Need 2 consecutive stable checks (reduced for speed) + + while (Date.now() - start < timeout) { + try { + const result = await sendCdp( + 'Runtime.evaluate', + { + expression: `(() => { + // Check for loading/progress indicators (excluding notification center) + const progressContainers = document.querySelectorAll('.monaco-progress-container:not(.done)'); + // Filter out progress bars in notification center + let hasProgress = false; + for (const p of progressContainers) { + if (!p.closest('.notifications-center, .notifications-toasts')) { + hasProgress = true; + break; + } + } + + // Check for spinning icons (loading state) + const hasSpinner = !!document.querySelector('.codicon-loading, .codicon-sync-spin'); + + // Check if status bar shows "activating" state + const statusBar = document.querySelector('.statusbar'); + const statusText = statusBar?.textContent || ''; + const isActivating = statusText.toLowerCase().includes('activating'); + + return JSON.stringify({ + hasProgress, + hasSpinner, + isActivating, + }); + })()`, + returnByValue: true, + }, + ws, + ); + const state = JSON.parse(result.result.value); + const isStable = !state.hasProgress && !state.hasSpinner && !state.isActivating; + + if (isStable) { + stableCount++; + logger(`Extension Host appears stable (${stableCount}/${requiredStableChecks})`); + if (stableCount >= requiredStableChecks) { + logger('Extension Host ready'); + return; + } + } else { + stableCount = 0; + logger( + `Extension Host still initializing: progress=${state.hasProgress}, spinner=${state.hasSpinner}, activating=${state.isActivating}`, + ); + } + } catch { + stableCount = 0; + } + await new Promise(r => setTimeout(r, 300)); + } + logger('Extension Host initialization timeout — proceeding anyway'); +} + // ── Dev Host Bridge Discovery ─────────────────────────── /** @@ -826,6 +903,7 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { // Monitor for unexpected disconnects cdpWs.on('close', () => { logger('CDP WebSocket closed unexpectedly'); + clearAllData(); // Clear all stored console/network/trace data cdpWs = undefined; puppeteerBrowser = undefined; }); @@ -843,6 +921,9 @@ async function doConnect(options: VSCodeLaunchOptions): Promise { export async function stopDebugWindow(): Promise { puppeteerBrowser = undefined; + // Clear all stored console/network/trace data + clearAllData(); + try { cdpWs?.close(); } catch { diff --git a/src/cdp-events.ts b/src/cdp-events.ts index 67758a1d6..f19d4b57c 100644 --- a/src/cdp-events.ts +++ b/src/cdp-events.ts @@ -61,6 +61,10 @@ export interface TraceData { } // ── Storage ───────────────────────────────────────────── +// Data is stored per-session and cleared automatically on: +// - Connection reconnect (new generation) +// - Server exit (SIGINT, SIGTERM, uncaughtException) +// - WebSocket close let consoleMessages: ConsoleMessage[] = []; let networkRequests: Map = new Map(); @@ -410,3 +414,43 @@ export async function stopTrace(): Promise { export function getTraceData(): TraceData { return traceData; } + +// ── Process Exit Cleanup ──────────────────────────────── +// Ensure data is cleared when the server exits for any reason + +function cleanupOnExit(): void { + clearAllData(); + if (eventListenerCleanup) { + eventListenerCleanup(); + eventListenerCleanup = undefined; + } +} + +// Clean exit +process.on('exit', cleanupOnExit); + +// SIGINT (Ctrl+C) +process.on('SIGINT', () => { + cleanupOnExit(); + process.exit(0); +}); + +// SIGTERM (kill command) +process.on('SIGTERM', () => { + cleanupOnExit(); + process.exit(0); +}); + +// Uncaught exceptions - cleanup before crash +process.on('uncaughtException', (err) => { + console.error('Uncaught exception:', err); + cleanupOnExit(); + process.exit(1); +}); + +// Unhandled promise rejections +process.on('unhandledRejection', (reason) => { + console.error('Unhandled rejection:', reason); + cleanupOnExit(); + process.exit(1); +}); diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 3b2309667..8e951b960 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -15,7 +15,7 @@ import {defineTool} from './ToolDefinition.js'; export const screenshot = defineTool({ name: 'take_screenshot', description: `Take a screenshot of the page or element.`, - timeoutMs: 10000, + timeoutMs: 45000, // Allow time for VS Code Extension Host to fully initialize annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, From 24ac439d6ae64761aeb2c6d885035e4b3eed7b36 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 06:10:49 -0600 Subject: [PATCH 010/127] Refactor input automation tools and documentation - Updated tool reference documentation to reflect changes in input automation tools, removing `fill`, `fill_form`, and `upload_file`, and adding `type`, `hotkey`, and `scroll`. - Renamed `fill` to `type` in the codebase and updated related tests. - Removed `fill_form` and `upload_file` tools from the codebase and tests. - Introduced `scroll` tool to handle scrolling elements into view and within scrollable areas. - Updated `press_key` to `hotkey` in the codebase and tests. - Removed `wait_for` tool from the codebase and tests. - Cleaned up unused imports and code related to removed tools. - Adjusted CLI options and notification messages to align with the new tool names. --- README.md | 12 +- docs/tool-reference.md | 51 +-- scripts/eval_scenarios/input_parallel_test.ts | 2 +- scripts/eval_scenarios/input_test.ts | 2 +- skills/chrome-devtools/SKILL.md | 5 +- src/ax-tree.ts | 29 ++ src/cli.ts | 2 +- src/main.ts | 4 +- src/notification-gate.ts | 2 +- src/tools/debug-bridge-exec.ts | 105 ------ src/tools/debug-evaluate.ts | 97 +++++- src/tools/input.ts | 133 ++----- src/tools/snapshot.ts | 48 +-- src/tools/tools.ts | 2 - tests/index.test.ts | 11 - tests/tools/input.test.ts | 326 ++++-------------- tests/tools/snapshot.test.ts | 109 +----- 17 files changed, 248 insertions(+), 692 deletions(-) delete mode 100644 src/tools/debug-bridge-exec.ts diff --git a/README.md b/README.md index 3061d9519..207c804e4 100644 --- a/README.md +++ b/README.md @@ -353,22 +353,20 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles -- **Input automation** (8 tools) +- **Input automation** (5 tools) - [`click`](docs/tool-reference.md#click) - [`drag`](docs/tool-reference.md#drag) - - [`fill`](docs/tool-reference.md#fill) - - [`fill_form`](docs/tool-reference.md#fill_form) + - [`type`](docs/tool-reference.md#type) - [`handle_dialog`](docs/tool-reference.md#handle_dialog) - [`hover`](docs/tool-reference.md#hover) - - [`press_key`](docs/tool-reference.md#press_key) - - [`upload_file`](docs/tool-reference.md#upload_file) -- **Navigation automation** (6 tools) + - [`hotkey`](docs/tool-reference.md#hotkey) + - [`scroll`](docs/tool-reference.md#scroll) +- **Navigation automation** (5 tools) - [`close_page`](docs/tool-reference.md#close_page) - [`list_pages`](docs/tool-reference.md#list_pages) - [`navigate_page`](docs/tool-reference.md#navigate_page) - [`new_page`](docs/tool-reference.md#new_page) - [`select_page`](docs/tool-reference.md#select_page) - - [`wait_for`](docs/tool-reference.md#wait_for) - **Emulation** (2 tools) - [`emulate`](docs/tool-reference.md#emulate) - [`resize_page`](docs/tool-reference.md#resize_page) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 04a9db529..a39ce070d 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -2,22 +2,20 @@ # Chrome DevTools MCP Tool Reference -- **[Input automation](#input-automation)** (8 tools) +- **[Input automation](#input-automation)** (5 tools) - [`click`](#click) - [`drag`](#drag) - - [`fill`](#fill) - - [`fill_form`](#fill_form) + - [`type`](#type) - [`handle_dialog`](#handle_dialog) - [`hover`](#hover) - - [`press_key`](#press_key) - - [`upload_file`](#upload_file) -- **[Navigation automation](#navigation-automation)** (6 tools) + - [`hotkey`](#hotkey) + - [`scroll`](#scroll) +- **[Navigation automation](#navigation-automation)** (5 tools) - [`close_page`](#close_page) - [`list_pages`](#list_pages) - [`navigate_page`](#navigate_page) - [`new_page`](#new_page) - [`select_page`](#select_page) - - [`wait_for`](#wait_for) - **[Emulation](#emulation)** (2 tools) - [`emulate`](#emulate) - [`resize_page`](#resize_page) @@ -61,25 +59,14 @@ --- -### `fill` +### `type` **Description:** Type text into a input, text area or select an option from a <select> element. **Parameters:** - **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot -- **value** (string) **(required)**: The value to [`fill`](#fill) in -- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. - ---- - -### `fill_form` - -**Description:** [`Fill`](#fill) out multiple form elements at once - -**Parameters:** - -- **elements** (array) **(required)**: Elements from snapshot to [`fill`](#fill) out. +- **value** (string) **(required)**: The value to type in - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. --- @@ -106,9 +93,9 @@ --- -### `press_key` +### `hotkey` -**Description:** Press a key or key combination. Use this when other input methods like [`fill`](#fill)() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations). +**Description:** Press a key or key combination. Use this when other input methods like [`type`](#type)() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations). **Parameters:** @@ -117,14 +104,15 @@ --- -### `upload_file` +### `scroll` -**Description:** Upload a file through a provided element. +**Description:** Scroll an element into view, or scroll within a scrollable element in a given direction. If no direction is provided, the element is simply scrolled into the viewport. **Parameters:** -- **filePath** (string) **(required)**: The local path of the file to upload -- **uid** (string) **(required)**: The uid of the file input element or an element that will open file chooser on the page from the page content snapshot +- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot +- **direction** (enum: "up", "down", "left", "right") _(optional)_: Direction to scroll within the element. If omitted, the element is scrolled into view without additional scrolling. +- **amount** (number) _(optional)_: Scroll distance in pixels. Default is 300. - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. --- @@ -187,17 +175,6 @@ --- -### `wait_for` - -**Description:** Wait for the specified text to appear on the selected page. - -**Parameters:** - -- **text** (string) **(required)**: Text to appear on the page -- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. - ---- - ## Emulation ### `emulate` diff --git a/scripts/eval_scenarios/input_parallel_test.ts b/scripts/eval_scenarios/input_parallel_test.ts index e93148036..b8a04a414 100644 --- a/scripts/eval_scenarios/input_parallel_test.ts +++ b/scripts/eval_scenarios/input_parallel_test.ts @@ -25,7 +25,7 @@ export const scenario: TestScenario = { calls[0].name === 'navigate_page' || calls[0].name === 'new_page', ); assert.ok(calls[1].name === 'take_snapshot'); - assert.ok(calls[2].name === 'fill'); + assert.ok(calls[2].name === 'type'); for (let i = 3; i < 8; i++) { assert.ok(calls[i].name === 'click'); assert.strictEqual(Boolean(calls[i].args.includeSnapshot), false); diff --git a/scripts/eval_scenarios/input_test.ts b/scripts/eval_scenarios/input_test.ts index 6078e7f96..20bf41a2f 100644 --- a/scripts/eval_scenarios/input_test.ts +++ b/scripts/eval_scenarios/input_test.ts @@ -25,7 +25,7 @@ export const scenario: TestScenario = { calls[0].name === 'navigate_page' || calls[0].name === 'new_page', ); assert.ok(calls[1].name === 'take_snapshot'); - assert.ok(calls[2].name === 'fill'); + assert.ok(calls[2].name === 'type'); assert.ok(calls[3].name === 'click'); }, }; diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index 551c03be8..7df5f5280 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -16,9 +16,8 @@ description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooti ### Before interacting with a page 1. Navigate: `navigate_page` or `new_page` -2. Wait: `wait_for` to ensure content is loaded if you know what you look for. -3. Snapshot: `take_snapshot` to understand page structure -4. Interact: Use element `uid`s from snapshot for `click`, `fill`, etc. +2. Snapshot: `take_snapshot` to understand page structure +3. Interact: Use element `uid`s from snapshot for `click`, `type`, etc. ### Efficient data retrieval diff --git a/src/ax-tree.ts b/src/ax-tree.ts index 2c429f97a..1d5558cea 100644 --- a/src/ax-tree.ts +++ b/src/ax-tree.ts @@ -270,6 +270,35 @@ export async function scrollIntoView(uid: string): Promise { await sendCdp('DOM.scrollIntoViewIfNeeded', {backendNodeId}); } +/** + * Scroll an element into view, then optionally dispatch a mouse wheel event + * at its center to scroll within the element in a given direction. + */ +export async function scrollElement( + uid: string, + direction?: 'up' | 'down' | 'left' | 'right', + amount = 300, +): Promise { + await scrollIntoView(uid); + + if (!direction) return; + + const {x, y} = await getElementCenter(uid); + const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0; + const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0; + + logger(`Scrolling uid=${uid} at (${x}, ${y}), deltaX=${deltaX}, deltaY=${deltaY}`); + await sendCdp('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x, + y, + deltaX, + deltaY, + }); + // Allow layout to settle after scroll + await new Promise(r => setTimeout(r, 100)); +} + // ── Input Helpers ── /** diff --git a/src/cli.ts b/src/cli.ts index f50105159..ffa3622d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,7 +49,7 @@ export const cliOptions = { devDiagnostic: { type: 'boolean', describe: - 'Enable diagnostic development tools (debug_evaluate, debug_bridge_exec). Hidden in production.', + 'Enable diagnostic development tools (debug_evaluate). Hidden in production.', default: false, hidden: true, }, diff --git a/src/main.ts b/src/main.ts index 2e1aa83b3..e1d77170e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -263,10 +263,10 @@ function registerTool(tool: ToolDefinition): void { // BLOCKING modals (e.g., "Save file?") → STOP tool, return modal info // NON-BLOCKING notifications (toasts) → Prepend banner, let tool proceed // - // EXCEPTION: Input tools (press_key, click, click_at, hover, drag) BYPASS the gate + // EXCEPTION: Input tools (hotkey, click, hover, drag) BYPASS the gate // when there's a blocking UI. This allows the user to dismiss the dialog. // Without this, there would be no way to interact with blocking dialogs via MCP. - const inputTools = ['press_key', 'click', 'click_at', 'hover', 'drag', 'fill']; + const inputTools = ['hotkey', 'click', 'hover', 'drag', 'type', 'scroll']; const isInputTool = inputTools.includes(tool.name); const uiCheck = await checkForBlockingUI(); diff --git a/src/notification-gate.ts b/src/notification-gate.ts index e91ccc945..c78cdf214 100644 --- a/src/notification-gate.ts +++ b/src/notification-gate.ts @@ -267,7 +267,7 @@ export function formatBlockingModal(modal: PendingUIElement): string { lines.push(` - "${btn.label}"`); } lines.push(''); - lines.push('Use `click` on one of the dialog buttons, or `press_key` with "Escape" to dismiss.'); + lines.push('Use `click` on one of the dialog buttons, or `hotkey` with "Escape" to dismiss.'); } else { lines.push('Press Escape or click outside to dismiss this dialog.'); } diff --git a/src/tools/debug-bridge-exec.ts b/src/tools/debug-bridge-exec.ts deleted file mode 100644 index 92ca73235..000000000 --- a/src/tools/debug-bridge-exec.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Development diagnostic tool: execute arbitrary VS Code API code via the - * extension-bridge named pipe/socket. - * - * Hidden in production — kept for development and troubleshooting. - * Gives direct access to the VS Code extension API context (vscode namespace). - * - * The code runs inside `new Function('vscode', 'payload', ...)` in the - * extension host process. `require()` is NOT available — only `vscode` API - * and `payload` are in scope. - */ - -import {zod} from '../third_party/index.js'; - -import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; -import {bridgeExec} from '../bridge-client.js'; -import {getHostBridgePath, getDevhostBridgePath} from '../browser.js'; - -export const debugBridgeExec = defineTool({ - name: 'debug_bridge_exec', - description: `[DEV] Execute arbitrary VS Code API code via the extension-bridge. -The code runs in a \`new Function('vscode', 'payload', ...)\` context inside the -extension host process. \`require()\` is NOT available. - -Use 'host' target for the controller VS Code, or 'devhost' for the spawned -Extension Development Host window. - -Examples: -- \`return vscode.version;\` — get VS Code version -- \`return vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath);\` — list workspace folders -- \`return vscode.window.tabGroups.all.flatMap(g => g.tabs.map(t => ({label: t.label, active: t.isActive})));\` — list editor tabs -- \`const editor = vscode.window.activeTextEditor; return editor ? { file: editor.document.fileName, line: editor.selection.active.line } : null;\` — get active editor info -- \`return vscode.extensions.all.filter(e => e.isActive).map(e => e.id);\` — list active extensions`, - timeoutMs: 10000, - annotations: { - category: ToolCategory.DEBUGGING, - readOnlyHint: false, - conditions: ['devDiagnostic'], - }, - schema: { - code: zod - .string() - .describe( - 'VS Code API code to execute. Must use `return` to return a value. ' + - 'Runs inside an async function body, so `await` is available. ' + - '`vscode` and `payload` are in scope. `require()` is NOT available.', - ), - target: zod - .enum(['host', 'devhost']) - .optional() - .default('devhost') - .describe( - 'Which VS Code instance to target. ' + - '"host" = the controller VS Code with extension-bridge. ' + - '"devhost" = the spawned Extension Development Host window. ' + - 'Default: "devhost".', - ), - payload: zod - .unknown() - .optional() - .describe( - 'Optional JSON-serializable payload passed as the `payload` parameter.', - ), - }, - handler: async (request, response) => { - const {code, target, payload} = request.params; - - const bridgePath = - target === 'host' ? getHostBridgePath() : getDevhostBridgePath(); - - if (!bridgePath) { - const targetLabel = - target === 'host' ? 'Host VS Code' : 'Extension Development Host'; - response.appendResponseLine( - `**Error:** ${targetLabel} bridge is not connected.\n` + - 'Ensure the VS Code debug window has been launched and extension-bridge is active.', - ); - return; - } - - try { - const result = await bridgeExec(bridgePath, code, payload); - response.appendResponseLine('**Result:**'); - response.appendResponseLine('```json'); - response.appendResponseLine( - typeof result === 'string' - ? result - : JSON.stringify(result, null, 2), - ); - response.appendResponseLine('```'); - } catch (err) { - response.appendResponseLine('**Bridge exec error:**'); - response.appendResponseLine('```'); - response.appendResponseLine((err as Error).message); - response.appendResponseLine('```'); - } - }, -}); diff --git a/src/tools/debug-evaluate.ts b/src/tools/debug-evaluate.ts index 894d50e8f..870bf6b5f 100644 --- a/src/tools/debug-evaluate.ts +++ b/src/tools/debug-evaluate.ts @@ -6,30 +6,46 @@ /** * Development diagnostic tool: execute arbitrary JavaScript in the VS Code - * workbench renderer via CDP Runtime.evaluate. + * workbench renderer via CDP Runtime.evaluate, or execute VS Code API code + * via the extension-bridge named pipe/socket. * * Hidden in production — kept for development and troubleshooting. - * Gives direct access to the Electron renderer context (DOM, window, document). + * + * Supports two targets: + * - "renderer" (default): Runs in the Electron renderer process context + * (document, window, etc.) via CDP Runtime.evaluate. + * - "host" / "devhost": Runs VS Code API code via the extension-bridge. + * The code runs inside `new Function('vscode', 'payload', ...)` in the + * extension host process. `require()` is NOT available. */ import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; -import {sendCdp} from '../browser.js'; +import {sendCdp, getHostBridgePath, getDevhostBridgePath} from '../browser.js'; +import {bridgeExec} from '../bridge-client.js'; export const debugEvaluate = defineTool({ name: 'debug_evaluate', - description: `[DEV] Execute arbitrary JavaScript in the VS Code workbench renderer context via CDP Runtime.evaluate. -Returns the result as JSON. Use for inspecting DOM state, console output, window properties, -or any renderer-side diagnostics. The expression runs in the Electron renderer process context -(document, window, etc.). + description: `[DEV] Execute arbitrary JavaScript in the VS Code workbench renderer context via CDP Runtime.evaluate, +or execute VS Code API code via the extension-bridge. + +Use 'renderer' target (default) for DOM/window inspection via CDP. +Use 'host' or 'devhost' target for VS Code API calls via the extension-bridge. -Examples: +Renderer examples: - \`document.title\` — get window title - \`document.querySelector('.monaco-workbench')?.className\` — check workbench state - \`JSON.stringify(performance.getEntriesByType('navigation'))\` — navigation timing -- \`Array.from(document.querySelectorAll('.notification-toast')).map(n => n.textContent)\` — list notifications`, +- \`Array.from(document.querySelectorAll('.notification-toast')).map(n => n.textContent)\` — list notifications + +Bridge examples (target='host' or 'devhost'): +- \`return vscode.version;\` — get VS Code version +- \`return vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath);\` — list workspace folders +- \`return vscode.window.tabGroups.all.flatMap(g => g.tabs.map(t => ({label: t.label, active: t.isActive})));\` — list editor tabs +- \`const editor = vscode.window.activeTextEditor; return editor ? { file: editor.document.fileName, line: editor.selection.active.line } : null;\` — get active editor info +- \`return vscode.extensions.all.filter(e => e.isActive).map(e => e.id);\` — list active extensions`, timeoutMs: 10000, annotations: { category: ToolCategory.DEBUGGING, @@ -40,20 +56,73 @@ Examples: expression: zod .string() .describe( - 'JavaScript expression to evaluate in the VS Code renderer context. ' + - 'Must be a valid expression (not a statement). For multi-line logic, ' + - 'wrap in an IIFE: `(() => { ... })()`.', + 'JavaScript expression or VS Code API code to execute. ' + + 'For renderer target: must be a valid expression (not a statement). ' + + 'For multi-line logic, wrap in an IIFE: `(() => { ... })()`. ' + + 'For host/devhost targets: must use `return` to return a value. ' + + 'Runs inside an async function body, so `await` is available. ' + + '`vscode` and `payload` are in scope. `require()` is NOT available.', + ), + target: zod + .enum(['renderer', 'host', 'devhost']) + .optional() + .default('renderer') + .describe( + 'Which VS Code context to target. ' + + '"renderer" = CDP Runtime.evaluate in the Electron renderer (DOM, window). ' + + '"host" = the controller VS Code with extension-bridge. ' + + '"devhost" = the spawned Extension Development Host window. ' + + 'Default: "renderer".', ), returnByValue: zod .boolean() .optional() .default(true) .describe( - 'Whether to return the result by value (serialized). Default true.', + 'Whether to return the result by value (serialized). Default true. Only used for renderer target.', + ), + payload: zod + .unknown() + .optional() + .describe( + 'Optional JSON-serializable payload passed as the `payload` parameter. Only used for host/devhost targets.', ), }, handler: async (request, response) => { - const {expression, returnByValue} = request.params; + const {expression, target, returnByValue, payload} = request.params; + + if (target === 'host' || target === 'devhost') { + const bridgePath = + target === 'host' ? getHostBridgePath() : getDevhostBridgePath(); + + if (!bridgePath) { + const targetLabel = + target === 'host' ? 'Host VS Code' : 'Extension Development Host'; + response.appendResponseLine( + `**Error:** ${targetLabel} bridge is not connected.\n` + + 'Ensure the VS Code debug window has been launched and extension-bridge is active.', + ); + return; + } + + try { + const result = await bridgeExec(bridgePath, expression, payload); + response.appendResponseLine('**Result:**'); + response.appendResponseLine('```json'); + response.appendResponseLine( + typeof result === 'string' + ? result + : JSON.stringify(result, null, 2), + ); + response.appendResponseLine('```'); + } catch (err) { + response.appendResponseLine('**Bridge exec error:**'); + response.appendResponseLine('```'); + response.appendResponseLine((err as Error).message); + response.appendResponseLine('```'); + } + return; + } const result = await sendCdp('Runtime.evaluate', { expression, diff --git a/src/tools/input.ts b/src/tools/input.ts index 8909728e3..5a21f1c2a 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -5,7 +5,6 @@ */ import { - clickAtCoords, clickElement, dragElement, executeWithDiff, @@ -13,8 +12,8 @@ import { fillElement, hoverElement, pressKey, + scrollElement, } from '../ax-tree.js'; -import {sendCdp} from '../browser.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; @@ -88,36 +87,6 @@ export const click = defineTool({ }, }); -export const clickAt = defineTool({ - name: 'click_at', - description: `Clicks at the specified coordinates on the page`, - timeoutMs: 10000, - annotations: { - category: ToolCategory.INPUT, - readOnlyHint: false, - conditions: ['directCdp'], - }, - schema: { - x: zod.number().describe('The x coordinate to click at'), - y: zod.number().describe('The y coordinate to click at'), - dblClick: dblClickSchema, - includeSnapshot: includeSnapshotSchema, - }, - handler: async (request, response) => { - const {x, y, dblClick, includeSnapshot} = request.params; - await executeWithChanges( - async () => clickAtCoords(x, y, dblClick ? 2 : 1), - includeSnapshot, - response, - ); - response.appendResponseLine( - dblClick - ? 'Double clicked at the coordinates' - : 'Clicked at the coordinates', - ); - }, -}); - export const hover = defineTool({ name: 'hover', description: `Hover over the provided element`, @@ -146,8 +115,8 @@ export const hover = defineTool({ }, }); -export const fill = defineTool({ - name: 'fill', +export const type = defineTool({ + name: 'type', description: `Type text into a input, text area or select an option from a `); await context.createTextSnapshot(); - await fill.handler( + await type.handler( { params: { uid: '1_1', @@ -331,7 +265,7 @@ describe('input', () => { >`, ); await context.createTextSnapshot(); - await fill.handler( + await type.handler( { params: { uid: '1_1', @@ -360,7 +294,7 @@ describe('input', () => { await page.focus('textarea'); await context.createTextSnapshot(); await page.setDefaultTimeout(1000); - await fill.handler( + await type.handler( { params: { uid: '1_1', @@ -385,7 +319,7 @@ describe('input', () => { }); }); - it('reproduction: fill isolation', async () => { + it('reproduction: type isolation', async () => { await withMcpContext(async (_response, context) => { const page = context.getSelectedPage(); await page.setContent( @@ -404,7 +338,7 @@ describe('input', () => { // Fill email const response1 = new McpResponse(); - await fill.handler( + await type.handler( { params: { uid: '1_1', // email input @@ -421,7 +355,7 @@ describe('input', () => { // Fill password const response2 = new McpResponse(); - await fill.handler( + await type.handler( { params: { uid: '1_2', // password input @@ -513,185 +447,7 @@ describe('input', () => { }); }); - describe('fill form', () => { - it('successfully fills out the form', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html`
- - - -
`, - ); - await context.createTextSnapshot(); - await fillForm.handler( - { - params: { - elements: [ - { - uid: '1_2', - value: 'test', - }, - { - uid: '1_4', - value: 'test2', - }, - ], - }, - }, - response, - context, - ); - assert.ok(response.includeSnapshot); - assert.strictEqual( - response.responseLines[0], - 'Successfully filled out the form', - ); - assert.deepStrictEqual( - await page.evaluate(() => { - return [ - // @ts-expect-error missing types - document.querySelector('input[name=username]').value, - // @ts-expect-error missing types - document.querySelector('input[name=email]').value, - ]; - }), - ['test', 'test2'], - ); - }); - }); - }); - - describe('uploadFile', () => { - it('uploads a file to a file input', async () => { - const testFilePath = path.join(process.cwd(), 'test.txt'); - await fs.writeFile(testFilePath, 'test file content'); - - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html`
- -
`, - ); - await context.createTextSnapshot(); - await uploadFile.handler( - { - params: { - uid: '1_1', - filePath: testFilePath, - }, - }, - response, - context, - ); - assert.ok(response.includeSnapshot); - assert.strictEqual( - response.responseLines[0], - `File uploaded from ${testFilePath}.`, - ); - }); - - await fs.unlink(testFilePath); - }); - - it('uploads a file when clicking an element opens a file uploader', async () => { - const testFilePath = path.join(process.cwd(), 'test.txt'); - await fs.writeFile(testFilePath, 'test file content'); - - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html` - - `, - ); - await context.createTextSnapshot(); - await uploadFile.handler( - { - params: { - uid: '1_1', - filePath: testFilePath, - }, - }, - response, - context, - ); - assert.ok(response.includeSnapshot); - assert.strictEqual( - response.responseLines[0], - `File uploaded from ${testFilePath}.`, - ); - const uploadedFileName = await page.$eval('#file-input', el => { - const input = el as HTMLInputElement; - return input.files?.[0]?.name; - }); - assert.strictEqual(uploadedFileName, 'test.txt'); - - await fs.unlink(testFilePath); - }); - }); - - it('throws an error if the element is not a file input and does not open a file chooser', async () => { - const testFilePath = path.join(process.cwd(), 'test.txt'); - await fs.writeFile(testFilePath, 'test file content'); - - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent(html`
Not a file input
`); - await context.createTextSnapshot(); - - await assert.rejects( - uploadFile.handler( - { - params: { - uid: '1_1', - filePath: testFilePath, - }, - }, - response, - context, - ), - { - message: - 'Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.', - }, - ); - - assert.strictEqual(response.responseLines.length, 0); - assert.strictEqual(response.snapshotParams, undefined); - - await fs.unlink(testFilePath); - }); - }); - }); - - describe('press_key', () => { + describe('hotkey', () => { it('parses keys', () => { assert.deepStrictEqual(parseKey('Shift+A'), ['A', 'Shift']); assert.deepStrictEqual(parseKey('Shift++'), ['+', 'Shift']); @@ -719,7 +475,7 @@ describe('input', () => { }); }); - it('processes press_key', async () => { + it('processes hotkey', async () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPage(); await page.setContent( @@ -731,7 +487,7 @@ describe('input', () => { ); await context.createTextSnapshot(); - await pressKeyTool.handler( + await hotkeyTool.handler( { params: { key: 'Control+Shift+C', @@ -752,4 +508,60 @@ describe('input', () => { }); }); }); + + describe('scroll', () => { + it('scrolls element into view', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html`
spacer
+ `, + ); + await context.createTextSnapshot(); + await scroll.handler( + { + params: { + uid: '1_2', + }, + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[response.responseLines.length - 1], + 'Scrolled element into view', + ); + }); + }); + + it('scrolls down within a scrollable element', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html`
+
tall content
+
`, + ); + await context.createTextSnapshot(); + await scroll.handler( + { + params: { + uid: '1_1', + direction: 'down', + amount: 200, + }, + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[response.responseLines.length - 1], + 'Scrolled down by 200px within the element', + ); + }); + }); + }); }); diff --git a/tests/tools/snapshot.test.ts b/tests/tools/snapshot.test.ts index 795e3d416..603b45411 100644 --- a/tests/tools/snapshot.test.ts +++ b/tests/tools/snapshot.test.ts @@ -7,8 +7,8 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {takeSnapshot, waitFor} from '../../src/tools/snapshot.js'; -import {html, withMcpContext} from '../utils.js'; +import {takeSnapshot} from '../../src/tools/snapshot.js'; +import {withMcpContext} from '../utils.js'; describe('snapshot', () => { describe('browser_snapshot', () => { @@ -19,109 +19,4 @@ describe('snapshot', () => { }); }); }); - describe('browser_wait_for', () => { - it('should work', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - - await page.setContent( - html`
Hello
World
`, - ); - await waitFor.handler( - { - params: { - text: 'Hello', - }, - }, - response, - context, - ); - - assert.equal( - response.responseLines[0], - 'Element with text "Hello" found.', - ); - assert.ok(response.includeSnapshot); - }); - }); - it('should work with element that show up later', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - - const handlePromise = waitFor.handler( - { - params: { - text: 'Hello World', - }, - }, - response, - context, - ); - - await page.setContent( - html`
Hello
World
`, - ); - - await handlePromise; - - assert.equal( - response.responseLines[0], - 'Element with text "Hello World" found.', - ); - assert.ok(response.includeSnapshot); - }); - }); - it('should work with aria elements', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - - await page.setContent( - html`

Header

Text
`, - ); - - await waitFor.handler( - { - params: { - text: 'Header', - }, - }, - response, - context, - ); - - assert.equal( - response.responseLines[0], - 'Element with text "Header" found.', - ); - assert.ok(response.includeSnapshot); - }); - }); - - it('should work with iframe content', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - - await page.setContent( - html`

Top level

- `, - ); - - await waitFor.handler( - { - params: { - text: 'Hello iframe', - }, - }, - response, - context, - ); - - assert.equal( - response.responseLines[0], - 'Element with text "Hello iframe" found.', - ); - assert.ok(response.includeSnapshot); - }); - }); - }); }); From c61cf86660df1cb6ffb654d34a88fe2e6314c7a6 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 19:13:10 -0600 Subject: [PATCH 011/127] feat: enhance console message retrieval with text and source filtering options --- src/cdp-events.ts | 16 +++++++- src/tools/console.ts | 14 +++++++ src/tools/tools.ts | 2 + src/tools/wait.ts | 51 ++++++++++++++++++++++++ tests/tools/console.test.ts | 77 +++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/tools/wait.ts diff --git a/src/cdp-events.ts b/src/cdp-events.ts index f19d4b57c..3f5f54f9c 100644 --- a/src/cdp-events.ts +++ b/src/cdp-events.ts @@ -278,10 +278,12 @@ export function clearAllData(): void { } /** - * Get all console messages, optionally filtered by type. + * Get all console messages, optionally filtered by type, text content, or source URL. */ export function getConsoleMessages(options?: { types?: string[]; + textFilter?: string; + sourceFilter?: string; pageSize?: number; pageIdx?: number; }): {messages: ConsoleMessage[]; total: number} { @@ -292,6 +294,18 @@ export function getConsoleMessages(options?: { filtered = filtered.filter(m => typeSet.has(m.type)); } + if (options?.textFilter) { + const needle = options.textFilter.toLowerCase(); + filtered = filtered.filter(m => m.text.toLowerCase().includes(needle)); + } + + if (options?.sourceFilter) { + const needle = options.sourceFilter.toLowerCase(); + filtered = filtered.filter(m => + m.stackTrace?.some(frame => frame.url.toLowerCase().includes(needle)), + ); + } + const total = filtered.length; if (options?.pageSize !== undefined) { diff --git a/src/tools/console.ts b/src/tools/console.ts index 3cdd50e38..426289600 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -66,6 +66,18 @@ export const listConsoleMessages = defineTool({ .describe( 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.', ), + textFilter: zod + .string() + .optional() + .describe( + 'Case-insensitive substring to match against the message text. Only messages whose text contains this string are returned.', + ), + sourceFilter: zod + .string() + .optional() + .describe( + 'Substring to match against the source URL in the stack trace. Only messages originating from a matching source are returned.', + ), includePreservedMessages: zod .boolean() .default(false) @@ -77,6 +89,8 @@ export const listConsoleMessages = defineTool({ handler: async (request, response) => { const {messages, total} = getConsoleMessages({ types: request.params.types, + textFilter: request.params.textFilter, + sourceFilter: request.params.sourceFilter, pageSize: request.params.pageSize, pageIdx: request.params.pageIdx, }); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index eb0d1f088..d07449966 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -12,6 +12,7 @@ import * as performanceTools from './performance.js'; import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; import * as snapshotTools from './snapshot.js'; +import * as waitTools from './wait.js'; import type {ToolDefinition} from './ToolDefinition.js'; const tools = [ @@ -23,6 +24,7 @@ const tools = [ ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(waitTools), ] as ToolDefinition[]; tools.sort((a, b) => { diff --git a/src/tools/wait.ts b/src/tools/wait.ts new file mode 100644 index 000000000..9d13becc3 --- /dev/null +++ b/src/tools/wait.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +export const wait = defineTool({ + name: 'wait', + description: + 'Wait for a specified duration before continuing. Useful for giving the page time to update, animations to complete, or network requests to settle.', + timeoutMs: 35000, + annotations: { + category: ToolCategory.INPUT, + readOnlyHint: true, + }, + schema: { + durationMs: zod + .number() + .int() + .min(0) + .max(30000) + .describe( + 'Duration to wait in milliseconds. Must be between 0 and 30000 (30 seconds).', + ), + reason: zod + .string() + .optional() + .describe( + 'Optional reason for waiting (e.g., "waiting for animation to complete"). Included in the response for context.', + ), + }, + handler: async (request, response) => { + const {durationMs, reason} = request.params; + const startTime = Date.now(); + + await new Promise(resolve => setTimeout(resolve, durationMs)); + + const elapsed = Date.now() - startTime; + + if (reason) { + response.appendResponseLine(`Waited ${elapsed}ms (${reason}).`); + } else { + response.appendResponseLine(`Waited ${elapsed}ms.`); + } + }, +}); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index bb074377c..bb0034c74 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -66,6 +66,83 @@ describe('console', () => { }); }); + describe('textFilter', () => { + it('returns only messages matching the text substring', async () => { + await withMcpContext(async (response, context) => { + const page = await context.newPage(); + await page.setContent( + '', + ); + await listConsoleMessages.handler( + {params: {textFilter: 'hello'}}, + response, + context, + ); + const formattedResponse = await response.handle('test', context); + const textContent = getTextContent(formattedResponse.content[0]); + assert.ok(textContent.includes('hello world')); + assert.ok(textContent.includes('hello error')); + assert.ok(!textContent.includes('goodbye world')); + }); + }); + + it('is case-insensitive', async () => { + await withMcpContext(async (response, context) => { + const page = await context.newPage(); + await page.setContent( + '', + ); + await listConsoleMessages.handler( + {params: {textFilter: 'hello'}}, + response, + context, + ); + const formattedResponse = await response.handle('test', context); + const textContent = getTextContent(formattedResponse.content[0]); + assert.ok(textContent.includes('Hello World')); + assert.ok(!textContent.includes('other message')); + }); + }); + + it('returns no messages when nothing matches', async () => { + await withMcpContext(async (response, context) => { + const page = await context.newPage(); + await page.setContent( + '', + ); + await listConsoleMessages.handler( + {params: {textFilter: 'nonexistent'}}, + response, + context, + ); + const formattedResponse = await response.handle('test', context); + const textContent = getTextContent(formattedResponse.content[0]); + assert.ok(textContent.includes('No console messages found.')); + }); + }); + }); + + describe('combined filters', () => { + it('applies textFilter together with types filter', async () => { + await withMcpContext(async (response, context) => { + const page = await context.newPage(); + await page.setContent( + '', + ); + await listConsoleMessages.handler( + {params: {types: ['error'], textFilter: 'hello'}}, + response, + context, + ); + const formattedResponse = await response.handle('test', context); + const textContent = getTextContent(formattedResponse.content[0]); + assert.ok(textContent.includes('hello error')); + assert.ok(!textContent.includes('hello log')); + assert.ok(!textContent.includes('goodbye log')); + }); + }); + }); + describe('issues', () => { it('lists issues', async () => { await withMcpContext(async (response, context) => { From 73a02168a51040323a9ce08fb5f6d19a6d73a0f2 Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 19:20:27 -0600 Subject: [PATCH 012/127] feat: enhance tool registration to support standalone tools and improve VS Code connection handling --- src/main.ts | 25 +++++++++++++++++++++++-- src/tools/debug-evaluate.ts | 25 +++++++++++-------------- src/tools/wait.ts | 1 + 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/main.ts b/src/main.ts index e1d77170e..d32944111 100644 --- a/src/main.ts +++ b/src/main.ts @@ -252,12 +252,33 @@ function registerTool(tool: ToolDefinition): void { // This runs OUTSIDE the mutex so it doesn't block on stale locks. devLazyRebuildCheck(); + // Standalone tools (e.g., wait) don't need VS Code connection + const isStandalone = tool.annotations.conditions?.includes('standalone'); + + // Ensure VS Code connection (CDP + bridge) OUTSIDE the timeout. + // This allows the first tool call to wait for Extension Host initialization + // without eating into the tool's timeout budget. + if (!isStandalone) { + await ensureConnection(); + } + const executeAll = async () => { guard = await toolMutex.acquire(); logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); - // Always ensure VS Code connection (CDP + bridge) - await ensureConnection(); + // Standalone tools don't need VS Code connection or UI checks + if (isStandalone) { + const response = new McpResponse(); + await tool.handler({params}, response, undefined as never); + const content: Array<{type: string; text?: string}> = []; + for (const line of response.responseLines) { + content.push({type: 'text', text: line}); + } + if (content.length === 0) { + content.push({type: 'text', text: '(no output)'}); + } + return {content}; + } // Check for blocking modals/notifications before tool execution // BLOCKING modals (e.g., "Save file?") → STOP tool, return modal info diff --git a/src/tools/debug-evaluate.ts b/src/tools/debug-evaluate.ts index 870bf6b5f..0774e1d19 100644 --- a/src/tools/debug-evaluate.ts +++ b/src/tools/debug-evaluate.ts @@ -14,16 +14,17 @@ * Supports two targets: * - "renderer" (default): Runs in the Electron renderer process context * (document, window, etc.) via CDP Runtime.evaluate. - * - "host" / "devhost": Runs VS Code API code via the extension-bridge. - * The code runs inside `new Function('vscode', 'payload', ...)` in the - * extension host process. `require()` is NOT available. + * - "devhost": Runs VS Code API code via the extension-bridge in the + * spawned Extension Development Host. The code runs inside + * `new Function('vscode', 'payload', ...)` in the extension host process. + * `require()` is NOT available. */ import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; -import {sendCdp, getHostBridgePath, getDevhostBridgePath} from '../browser.js'; +import {sendCdp, getDevhostBridgePath} from '../browser.js'; import {bridgeExec} from '../bridge-client.js'; export const debugEvaluate = defineTool({ @@ -32,7 +33,7 @@ export const debugEvaluate = defineTool({ or execute VS Code API code via the extension-bridge. Use 'renderer' target (default) for DOM/window inspection via CDP. -Use 'host' or 'devhost' target for VS Code API calls via the extension-bridge. +Use 'devhost' target for VS Code API calls via the extension-bridge. Renderer examples: - \`document.title\` — get window title @@ -40,7 +41,7 @@ Renderer examples: - \`JSON.stringify(performance.getEntriesByType('navigation'))\` — navigation timing - \`Array.from(document.querySelectorAll('.notification-toast')).map(n => n.textContent)\` — list notifications -Bridge examples (target='host' or 'devhost'): +Bridge examples (target='devhost'): - \`return vscode.version;\` — get VS Code version - \`return vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath);\` — list workspace folders - \`return vscode.window.tabGroups.all.flatMap(g => g.tabs.map(t => ({label: t.label, active: t.isActive})));\` — list editor tabs @@ -64,13 +65,12 @@ Bridge examples (target='host' or 'devhost'): '`vscode` and `payload` are in scope. `require()` is NOT available.', ), target: zod - .enum(['renderer', 'host', 'devhost']) + .enum(['renderer', 'devhost']) .optional() .default('renderer') .describe( 'Which VS Code context to target. ' + '"renderer" = CDP Runtime.evaluate in the Electron renderer (DOM, window). ' + - '"host" = the controller VS Code with extension-bridge. ' + '"devhost" = the spawned Extension Development Host window. ' + 'Default: "renderer".', ), @@ -91,15 +91,12 @@ Bridge examples (target='host' or 'devhost'): handler: async (request, response) => { const {expression, target, returnByValue, payload} = request.params; - if (target === 'host' || target === 'devhost') { - const bridgePath = - target === 'host' ? getHostBridgePath() : getDevhostBridgePath(); + if (target === 'devhost') { + const bridgePath = getDevhostBridgePath(); if (!bridgePath) { - const targetLabel = - target === 'host' ? 'Host VS Code' : 'Extension Development Host'; response.appendResponseLine( - `**Error:** ${targetLabel} bridge is not connected.\n` + + `**Error:** Extension Development Host bridge is not connected.\n` + 'Ensure the VS Code debug window has been launched and extension-bridge is active.', ); return; diff --git a/src/tools/wait.ts b/src/tools/wait.ts index 9d13becc3..5418a0bf4 100644 --- a/src/tools/wait.ts +++ b/src/tools/wait.ts @@ -17,6 +17,7 @@ export const wait = defineTool({ annotations: { category: ToolCategory.INPUT, readOnlyHint: true, + conditions: ['standalone'], }, schema: { durationMs: zod From 5327c9d8c768040bb00100e033b3312bbe82f2fa Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 19:32:00 -0600 Subject: [PATCH 013/127] feat: add output panel tool for reading VS Code logs and integrate into tools list --- src/browser.ts | 4 + src/tools/output-panel.ts | 298 ++++++++++++++++++++++++++++++++++++++ src/tools/tools.ts | 2 + 3 files changed, 304 insertions(+) create mode 100644 src/tools/output-panel.ts diff --git a/src/browser.ts b/src/browser.ts index de968fb44..851d60ec9 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -71,6 +71,10 @@ export function getConnectionGeneration(): number { return connectionGeneration; } +export function getUserDataDir(): string | undefined { + return userDataDir; +} + // ── Raw CDP Communication ─────────────────────────────── let cdpMessageId = 0; diff --git a/src/tools/output-panel.ts b/src/tools/output-panel.ts new file mode 100644 index 000000000..177c2d788 --- /dev/null +++ b/src/tools/output-panel.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool for reading output panel content from VS Code. + * Reads log files directly from the VS Code user data directory, + * so the Output panel does not need to be open in the GUI. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import {getUserDataDir} from '../browser.js'; +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +interface LogFileInfo { + name: string; + path: string; + size: number; + category: string; +} + +/** + * Recursively find all .log files in a directory. + */ +function findLogFiles(dir: string, category = 'root'): LogFileInfo[] { + const results: LogFileInfo[] = []; + + if (!fs.existsSync(dir)) { + return results; + } + + const entries = fs.readdirSync(dir, {withFileTypes: true}); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + let newCategory = category; + if (entry.name.startsWith('window')) { + newCategory = 'window'; + } else if (entry.name === 'exthost') { + newCategory = 'exthost'; + } else if (entry.name.startsWith('output_')) { + newCategory = 'output'; + } else if (entry.name.startsWith('vscode.')) { + newCategory = 'extension'; + } + results.push(...findLogFiles(fullPath, newCategory)); + } else if (entry.name.endsWith('.log')) { + const stats = fs.statSync(fullPath); + results.push({ + name: entry.name.replace('.log', ''), + path: fullPath, + size: stats.size, + category, + }); + } + } + + return results; +} + +/** + * Get the latest session logs directory. + */ +function getLatestLogsDir(): string | null { + const userDataDir = getUserDataDir(); + if (!userDataDir) { + return null; + } + + const logsDir = path.join(userDataDir, 'logs'); + if (!fs.existsSync(logsDir)) { + return null; + } + + const sessions = fs + .readdirSync(logsDir, {withFileTypes: true}) + .filter(d => d.isDirectory()) + .map(d => d.name) + .sort() + .reverse(); + + if (sessions.length === 0) { + return null; + } + + return path.join(logsDir, sessions[0]); +} + +export const listOutputChannels = defineTool({ + name: 'list_output_channels', + description: + 'List all available output channels in the VS Code Output panel (e.g., "Git", "TypeScript", "ESLint", "Extension Host").', + timeoutMs: 10000, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + conditions: ['directCdp'], + }, + schema: {}, + handler: async (_request, response) => { + const logsDir = getLatestLogsDir(); + + if (!logsDir) { + response.appendResponseLine( + 'No logs directory found. Make sure VS Code debug window is running.', + ); + return; + } + + const logFiles = findLogFiles(logsDir); + + if (logFiles.length === 0) { + response.appendResponseLine('No log files found.'); + return; + } + + const byCategory: Record = {}; + for (const file of logFiles) { + if (!byCategory[file.category]) { + byCategory[file.category] = []; + } + byCategory[file.category].push(file); + } + + response.appendResponseLine('## Available Output Channels\n'); + + const categoryLabels: Record = { + root: 'Main Logs', + window: 'Window Logs', + exthost: 'Extension Host', + extension: 'Extension Logs', + output: 'Output Channels', + }; + + for (const [category, files] of Object.entries(byCategory)) { + response.appendResponseLine( + `### ${categoryLabels[category] || category}\n`, + ); + for (const file of files) { + const sizeKb = (file.size / 1024).toFixed(1); + response.appendResponseLine(`- **${file.name}** (${sizeKb} KB)`); + } + response.appendResponseLine(''); + } + }, +}); + +export const getOutputPanelContent = defineTool({ + name: 'get_output_panel_content', + description: + 'Get the text content from the currently visible VS Code Output panel. Optionally switch to a specific output channel first.', + timeoutMs: 10000, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + conditions: ['directCdp'], + }, + schema: { + channel: zod + .string() + .optional() + .describe( + 'Name of the output channel to read (e.g., "Git", "TypeScript", "Extension Host"). If omitted, reads the currently visible channel.', + ), + maxLines: zod + .number() + .int() + .positive() + .optional() + .default(200) + .describe( + 'Maximum number of lines to return. Default is 200. Use a smaller value to reduce output size.', + ), + tail: zod + .boolean() + .optional() + .default(true) + .describe( + 'If true, returns the last N lines (most recent). If false, returns the first N lines. Default is true.', + ), + filter: zod + .string() + .optional() + .describe( + 'Case-insensitive substring filter. Only lines containing this text are returned.', + ), + }, + handler: async (request, response) => { + const {channel, maxLines, tail, filter} = request.params; + + const logsDir = getLatestLogsDir(); + + if (!logsDir) { + response.appendResponseLine( + 'No logs directory found. Make sure VS Code debug window is running.', + ); + return; + } + + const logFiles = findLogFiles(logsDir); + + if (logFiles.length === 0) { + response.appendResponseLine('No log files found.'); + return; + } + + let targetFile: LogFileInfo | undefined; + + if (channel) { + const needle = channel.toLowerCase(); + targetFile = logFiles.find(f => f.name.toLowerCase() === needle); + if (!targetFile) { + targetFile = logFiles.find(f => + f.name.toLowerCase().includes(needle), + ); + } + + if (!targetFile) { + response.appendResponseLine( + `Channel "${channel}" not found. Available channels:`, + ); + for (const file of logFiles) { + response.appendResponseLine(`- ${file.name}`); + } + return; + } + } else { + targetFile = + logFiles.find(f => f.name === 'exthost') || + logFiles.find(f => f.name === 'main') || + logFiles[0]; + } + + if (!targetFile) { + response.appendResponseLine('No log file selected.'); + return; + } + + let content: string; + try { + content = fs.readFileSync(targetFile.path, 'utf-8'); + } catch (err) { + response.appendResponseLine( + `Error reading log file: ${(err as Error).message}`, + ); + return; + } + + let lines = content.split('\n'); + + if (filter) { + const needle = filter.toLowerCase(); + lines = lines.filter(line => line.toLowerCase().includes(needle)); + } + + const effectiveMax = maxLines ?? 200; + const totalBeforeTrim = lines.length; + if (lines.length > effectiveMax) { + if (tail) { + lines = lines.slice(-effectiveMax); + } else { + lines = lines.slice(0, effectiveMax); + } + } + + response.appendResponseLine(`## Output: ${targetFile.name}\n`); + + if (filter) { + response.appendResponseLine(`_Filtered by: "${filter}"_\n`); + } + + if (totalBeforeTrim > effectiveMax) { + const position = tail ? 'last' : 'first'; + response.appendResponseLine( + `_Showing ${position} ${lines.length} of ${totalBeforeTrim} lines_\n`, + ); + } + + if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) { + response.appendResponseLine('(no output or log is empty)'); + } else { + response.appendResponseLine('```'); + for (const line of lines) { + response.appendResponseLine(line); + } + response.appendResponseLine('```'); + } + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index d07449966..7fe43a61c 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -8,6 +8,7 @@ import * as consoleTools from './console.js'; import * as debugEvaluateTools from './debug-evaluate.js'; import * as inputTools from './input.js'; import * as networkTools from './network.js'; +import * as outputPanelTools from './output-panel.js'; import * as performanceTools from './performance.js'; import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; @@ -20,6 +21,7 @@ const tools = [ ...Object.values(debugEvaluateTools), ...Object.values(inputTools), ...Object.values(networkTools), + ...Object.values(outputPanelTools), ...Object.values(performanceTools), ...Object.values(screenshotTools), ...Object.values(scriptTools), From c39df8acf8f768c0656e13f823abc22b0f0aee3f Mon Sep 17 00:00:00 2001 From: Andy Liner Date: Mon, 9 Feb 2026 20:22:27 -0600 Subject: [PATCH 014/127] refactor: remove obsolete tests and fixtures related to screenshot, script, and snapshot functionalities - Deleted screenshot tests to streamline testing for screenshot functionality. - Removed script tests that are no longer relevant to the current implementation. - Eliminated snapshot tests to reduce redundancy in test coverage. - Cleared out unused trace processing fixtures and related test files to maintain a clean codebase. - Updated tsconfig to exclude test files from compilation. --- src/cdp-events.ts | 65 +- src/tools/console.ts | 48 +- src/tools/output-panel.ts | 132 +- tests/DevtoolsUtils.test.ts | 159 -- tests/McpContext.test.js.snapshot | 45 - tests/McpContext.test.ts | 180 -- tests/McpResponse.test.js.snapshot | 1475 ------------ tests/McpResponse.test.ts | 971 -------- tests/PageCollector.test.ts | 418 ---- tests/cli.test.ts | 297 --- tests/e2e/telemetry.test.ts | 257 --- .../ConsoleFormatter.test.js.snapshot | 104 - tests/formatters/ConsoleFormatter.test.ts | 540 ----- .../IssueFormatter.test.js.snapshot | 9 - tests/formatters/IssueFormatter.test.ts | 209 -- tests/formatters/NetworkFormatter.test.ts | 471 ---- .../snapshotFormatter.test.js.snapshot | 20 - tests/formatters/snapshotFormatter.test.ts | 295 --- tests/index.test.ts | 134 -- tests/server.ts | 122 - tests/setup.ts | 34 - tests/snapshot.ts | 21 - tests/telemetry/clearcut-logger.test.ts | 136 -- tests/telemetry/flag-utils.test.ts | 107 - tests/telemetry/metric-utils.test.ts | 42 - tests/telemetry/persistence.test.ts | 70 - tests/telemetry/watchdog-client.test.ts | 139 -- .../watchdog/clearcut-sender.test.ts | 451 ---- tests/third_party_notices.test.js.snapshot | 2013 ----------------- tests/third_party_notices.test.ts | 26 - tests/tools/console.test.js.snapshot | 121 - tests/tools/console.test.ts | 467 ---- tests/tools/fixtures/extension/manifest.json | 8 - tests/tools/fixtures/extension/popup.html | 6 - tests/tools/input.test.ts | 567 ----- tests/tools/network.test.js.snapshot | 53 - tests/tools/network.test.ts | 179 -- tests/tools/performance.test.js.snapshot | 176 -- tests/tools/performance.test.ts | 427 ---- tests/tools/screenshot.test.ts | 264 --- tests/tools/script.test.ts | 188 -- tests/tools/snapshot.test.ts | 22 - .../fixtures/basic-trace.json.gz | Bin 845 -> 0 bytes tests/trace-processing/fixtures/load.ts | 43 - .../fixtures/web-dev-with-commit.json.gz | Bin 1006156 -> 0 bytes tests/trace-processing/parse.test.js.snapshot | 112 - tests/trace-processing/parse.test.ts | 49 - tests/utils.ts | 312 --- tsconfig.json | 1 - 49 files changed, 225 insertions(+), 11760 deletions(-) delete mode 100644 tests/DevtoolsUtils.test.ts delete mode 100644 tests/McpContext.test.js.snapshot delete mode 100644 tests/McpContext.test.ts delete mode 100644 tests/McpResponse.test.js.snapshot delete mode 100644 tests/McpResponse.test.ts delete mode 100644 tests/PageCollector.test.ts delete mode 100644 tests/cli.test.ts delete mode 100644 tests/e2e/telemetry.test.ts delete mode 100644 tests/formatters/ConsoleFormatter.test.js.snapshot delete mode 100644 tests/formatters/ConsoleFormatter.test.ts delete mode 100644 tests/formatters/IssueFormatter.test.js.snapshot delete mode 100644 tests/formatters/IssueFormatter.test.ts delete mode 100644 tests/formatters/NetworkFormatter.test.ts delete mode 100644 tests/formatters/snapshotFormatter.test.js.snapshot delete mode 100644 tests/formatters/snapshotFormatter.test.ts delete mode 100644 tests/index.test.ts delete mode 100644 tests/server.ts delete mode 100644 tests/setup.ts delete mode 100644 tests/snapshot.ts delete mode 100644 tests/telemetry/clearcut-logger.test.ts delete mode 100644 tests/telemetry/flag-utils.test.ts delete mode 100644 tests/telemetry/metric-utils.test.ts delete mode 100644 tests/telemetry/persistence.test.ts delete mode 100644 tests/telemetry/watchdog-client.test.ts delete mode 100644 tests/telemetry/watchdog/clearcut-sender.test.ts delete mode 100644 tests/third_party_notices.test.js.snapshot delete mode 100644 tests/third_party_notices.test.ts delete mode 100644 tests/tools/console.test.js.snapshot delete mode 100644 tests/tools/console.test.ts delete mode 100644 tests/tools/fixtures/extension/manifest.json delete mode 100644 tests/tools/fixtures/extension/popup.html delete mode 100644 tests/tools/input.test.ts delete mode 100644 tests/tools/network.test.js.snapshot delete mode 100644 tests/tools/network.test.ts delete mode 100644 tests/tools/performance.test.js.snapshot delete mode 100644 tests/tools/performance.test.ts delete mode 100644 tests/tools/screenshot.test.ts delete mode 100644 tests/tools/script.test.ts delete mode 100644 tests/tools/snapshot.test.ts delete mode 100644 tests/trace-processing/fixtures/basic-trace.json.gz delete mode 100644 tests/trace-processing/fixtures/load.ts delete mode 100644 tests/trace-processing/fixtures/web-dev-with-commit.json.gz delete mode 100644 tests/trace-processing/parse.test.js.snapshot delete mode 100644 tests/trace-processing/parse.test.ts delete mode 100644 tests/utils.ts diff --git a/src/cdp-events.ts b/src/cdp-events.ts index 3f5f54f9c..0f95a6a1c 100644 --- a/src/cdp-events.ts +++ b/src/cdp-events.ts @@ -278,33 +278,70 @@ export function clearAllData(): void { } /** - * Get all console messages, optionally filtered by type, text content, or source URL. + * Get all console messages, optionally filtered by type, text content, source URL, recency, and more. */ export function getConsoleMessages(options?: { types?: string[]; textFilter?: string; sourceFilter?: string; + isRegex?: boolean; + secondsAgo?: number; + filterLogic?: 'and' | 'or'; pageSize?: number; pageIdx?: number; }): {messages: ConsoleMessage[]; total: number} { let filtered = consoleMessages; - if (options?.types?.length) { - const typeSet = new Set(options.types); - filtered = filtered.filter(m => typeSet.has(m.type)); - } + const useOr = options?.filterLogic === 'or'; + const now = Date.now(); + const cutoffTime = options?.secondsAgo + ? now - options.secondsAgo * 1000 + : null; - if (options?.textFilter) { - const needle = options.textFilter.toLowerCase(); - filtered = filtered.filter(m => m.text.toLowerCase().includes(needle)); + let textRegex: RegExp | null = null; + if (options?.textFilter && options?.isRegex) { + try { + textRegex = new RegExp(options.textFilter, 'i'); + } catch { + textRegex = null; + } } - if (options?.sourceFilter) { - const needle = options.sourceFilter.toLowerCase(); - filtered = filtered.filter(m => - m.stackTrace?.some(frame => frame.url.toLowerCase().includes(needle)), - ); - } + filtered = filtered.filter(m => { + const checks: boolean[] = []; + + if (options?.types?.length) { + const typeSet = new Set(options.types); + checks.push(typeSet.has(m.type)); + } + + if (options?.textFilter) { + if (textRegex) { + checks.push(textRegex.test(m.text)); + } else { + const needle = options.textFilter.toLowerCase(); + checks.push(m.text.toLowerCase().includes(needle)); + } + } + + if (options?.sourceFilter) { + const needle = options.sourceFilter.toLowerCase(); + checks.push( + m.stackTrace?.some(frame => frame.url.toLowerCase().includes(needle)) ?? + false, + ); + } + + if (cutoffTime !== null) { + checks.push(m.timestamp >= cutoffTime); + } + + if (checks.length === 0) { + return true; + } + + return useOr ? checks.some(Boolean) : checks.every(Boolean); + }); const total = filtered.length; diff --git a/src/tools/console.ts b/src/tools/console.ts index 426289600..1b05b1dfb 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -78,6 +78,28 @@ export const listConsoleMessages = defineTool({ .describe( 'Substring to match against the source URL in the stack trace. Only messages originating from a matching source are returned.', ), + isRegex: zod + .boolean() + .optional() + .default(false) + .describe( + 'If true, treat textFilter as a regular expression pattern. Default is false (substring match).', + ), + secondsAgo: zod + .number() + .int() + .positive() + .optional() + .describe( + 'Only return messages from the last N seconds. Useful for filtering recent activity.', + ), + filterLogic: zod + .enum(['and', 'or']) + .optional() + .default('and') + .describe( + 'How to combine multiple filters. "and" = all filters must match (default). "or" = any filter can match.', + ), includePreservedMessages: zod .boolean() .default(false) @@ -91,6 +113,9 @@ export const listConsoleMessages = defineTool({ types: request.params.types, textFilter: request.params.textFilter, sourceFilter: request.params.sourceFilter, + isRegex: request.params.isRegex, + secondsAgo: request.params.secondsAgo, + filterLogic: request.params.filterLogic, pageSize: request.params.pageSize, pageIdx: request.params.pageIdx, }); @@ -100,7 +125,28 @@ export const listConsoleMessages = defineTool({ return; } - response.appendResponseLine(`Console messages (${messages.length} of ${total} total):\n`); + const filterParts: string[] = []; + if (request.params.types?.length) { + filterParts.push(`types: ${request.params.types.join(', ')}`); + } + if (request.params.textFilter) { + filterParts.push( + `text${request.params.isRegex ? ' (regex)' : ''}: "${request.params.textFilter}"`, + ); + } + if (request.params.sourceFilter) { + filterParts.push(`source: "${request.params.sourceFilter}"`); + } + if (request.params.secondsAgo) { + filterParts.push(`last ${request.params.secondsAgo}s`); + } + + let header = `Console messages (${messages.length} of ${total} total):`; + if (filterParts.length > 0) { + const logic = request.params.filterLogic === 'or' ? 'OR' : 'AND'; + header += `\n_Filters (${logic}): ${filterParts.join(' | ')}_`; + } + response.appendResponseLine(header + '\n'); for (const msg of messages) { const typeTag = `[${msg.type.toUpperCase()}]`; diff --git a/src/tools/output-panel.ts b/src/tools/output-panel.ts index 177c2d788..b8672c96c 100644 --- a/src/tools/output-panel.ts +++ b/src/tools/output-panel.ts @@ -154,6 +154,35 @@ export const listOutputChannels = defineTool({ }, }); +const LOG_LEVELS: [string, ...string[]] = [ + 'error', + 'warning', + 'info', + 'debug', + 'trace', +]; + +/** + * Parse timestamp from a VS Code log line. + * Format: 2026-02-09 19:31:05.070 [info] ... + */ +function parseLogTimestamp(line: string): Date | null { + const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})/); + if (match) { + return new Date(match[1].replace(' ', 'T')); + } + return null; +} + +/** + * Extract log level from a VS Code log line. + * Format: 2026-02-09 19:31:05.070 [info] ... + */ +function parseLogLevel(line: string): string | null { + const match = line.match(/\[(error|warning|info|debug|trace)\]/i); + return match ? match[1].toLowerCase() : null; +} + export const getOutputPanelContent = defineTool({ name: 'get_output_panel_content', description: @@ -193,9 +222,46 @@ export const getOutputPanelContent = defineTool({ .describe( 'Case-insensitive substring filter. Only lines containing this text are returned.', ), + isRegex: zod + .boolean() + .optional() + .default(false) + .describe( + 'If true, treat the filter as a regular expression pattern. Default is false (substring match).', + ), + levels: zod + .array(zod.enum(LOG_LEVELS)) + .optional() + .describe( + 'Filter by log level(s). Only lines with matching levels are returned. Levels: error, warning, info, debug, trace.', + ), + secondsAgo: zod + .number() + .int() + .positive() + .optional() + .describe( + 'Only return log lines from the last N seconds. Useful for filtering recent activity.', + ), + filterLogic: zod + .enum(['and', 'or']) + .optional() + .default('and') + .describe( + 'How to combine multiple filters. "and" = all filters must match (default). "or" = any filter can match.', + ), }, handler: async (request, response) => { - const {channel, maxLines, tail, filter} = request.params; + const { + channel, + maxLines, + tail, + filter, + isRegex, + levels, + secondsAgo, + filterLogic, + } = request.params; const logsDir = getLatestLogsDir(); @@ -257,11 +323,54 @@ export const getOutputPanelContent = defineTool({ let lines = content.split('\n'); - if (filter) { - const needle = filter.toLowerCase(); - lines = lines.filter(line => line.toLowerCase().includes(needle)); + const now = new Date(); + const cutoffTime = secondsAgo + ? new Date(now.getTime() - secondsAgo * 1000) + : null; + + const levelSet = levels?.length ? new Set(levels) : null; + + let filterRegex: RegExp | null = null; + if (filter && isRegex) { + try { + filterRegex = new RegExp(filter, 'i'); + } catch { + response.appendResponseLine( + `Invalid regex pattern: "${filter}". Falling back to substring match.`, + ); + } } + const useOr = filterLogic === 'or'; + + lines = lines.filter(line => { + const checks: boolean[] = []; + + if (filter) { + if (filterRegex) { + checks.push(filterRegex.test(line)); + } else { + checks.push(line.toLowerCase().includes(filter.toLowerCase())); + } + } + + if (levelSet) { + const lineLevel = parseLogLevel(line); + checks.push(lineLevel !== null && levelSet.has(lineLevel)); + } + + if (cutoffTime) { + const lineTime = parseLogTimestamp(line); + checks.push(lineTime !== null && lineTime >= cutoffTime); + } + + if (checks.length === 0) { + return true; + } + + return useOr ? checks.some(Boolean) : checks.every(Boolean); + }); + const effectiveMax = maxLines ?? 200; const totalBeforeTrim = lines.length; if (lines.length > effectiveMax) { @@ -274,8 +383,21 @@ export const getOutputPanelContent = defineTool({ response.appendResponseLine(`## Output: ${targetFile.name}\n`); + const filterParts: string[] = []; if (filter) { - response.appendResponseLine(`_Filtered by: "${filter}"_\n`); + filterParts.push(`text${isRegex ? ' (regex)' : ''}: "${filter}"`); + } + if (levelSet) { + filterParts.push(`levels: ${[...levelSet].join(', ')}`); + } + if (secondsAgo) { + filterParts.push(`last ${secondsAgo}s`); + } + if (filterParts.length > 0) { + const logic = useOr ? 'OR' : 'AND'; + response.appendResponseLine( + `_Filters (${logic}): ${filterParts.join(' | ')}_\n`, + ); } if (totalBeforeTrim > effectiveMax) { diff --git a/tests/DevtoolsUtils.test.ts b/tests/DevtoolsUtils.test.ts deleted file mode 100644 index a6f5d113c..000000000 --- a/tests/DevtoolsUtils.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import sinon from 'sinon'; - -import { - extractUrlLikeFromDevToolsTitle, - urlsEqual, - UniverseManager, -} from '../src/DevtoolsUtils.js'; -import {DevTools} from '../src/third_party/index.js'; -import type {Browser, Target} from '../src/third_party/index.js'; - -import { - getMockBrowser, - getMockPage, - mockListener, - withBrowser, -} from './utils.js'; - -describe('extractUrlFromDevToolsTitle', () => { - it('deals with no trailing /', () => { - assert.strictEqual( - extractUrlLikeFromDevToolsTitle('DevTools - example.com'), - 'example.com', - ); - }); - it('deals with a trailing /', () => { - assert.strictEqual( - extractUrlLikeFromDevToolsTitle('DevTools - example.com/'), - 'example.com/', - ); - }); - it('deals with www', () => { - assert.strictEqual( - extractUrlLikeFromDevToolsTitle('DevTools - www.example.com/'), - 'www.example.com/', - ); - }); - it('deals with complex url', () => { - assert.strictEqual( - extractUrlLikeFromDevToolsTitle( - 'DevTools - www.example.com/path.html?a=b#3', - ), - 'www.example.com/path.html?a=b#3', - ); - }); -}); - -describe('urlsEqual', () => { - it('ignores trailing slashes', () => { - assert.strictEqual( - urlsEqual('https://google.com/', 'https://google.com'), - true, - ); - }); - - it('ignores www', () => { - assert.strictEqual( - urlsEqual('https://google.com/', 'https://www.google.com'), - true, - ); - }); - - it('ignores protocols', () => { - assert.strictEqual( - urlsEqual('https://google.com/', 'http://www.google.com'), - true, - ); - }); - - it('does not ignore other subdomains', () => { - assert.strictEqual( - urlsEqual('https://google.com/', 'https://photos.google.com'), - false, - ); - }); - - it('ignores hash', () => { - assert.strictEqual( - urlsEqual('https://google.com/#', 'http://www.google.com'), - true, - ); - assert.strictEqual( - urlsEqual('https://google.com/#21', 'http://www.google.com#12'), - true, - ); - }); -}); - -describe('UniverseManager', () => { - it('calls the factory for existing pages', async () => { - const browser = getMockBrowser(); - const factory = sinon.stub().resolves({}); - const manager = new UniverseManager(browser, factory); - await manager.init(await browser.pages()); - - const page = (await browser.pages())[0]; - sinon.assert.calledOnceWithExactly(factory, page); - }); - - it('calls the factory only once for the same page', async () => { - const browser = { - ...mockListener(), - } as unknown as Browser; - // eslint-disable-next-line @typescript-eslint/no-empty-function - const factory = sinon.stub().returns(new Promise(() => {})); // Don't resolve. - const manager = new UniverseManager(browser, factory); - await manager.init([]); - - sinon.assert.notCalled(factory); - - const page = getMockPage(); - browser.emit('targetcreated', { - page: () => Promise.resolve(page), - } as Target); - browser.emit('targetcreated', { - page: () => Promise.resolve(page), - } as Target); - - await new Promise(r => setTimeout(r, 0)); // One event loop tick for the micro task queue to run. - - sinon.assert.calledOnceWithExactly(factory, page); - }); - - it('works with a real browser', async () => { - await withBrowser(async (browser, page) => { - const manager = new UniverseManager(browser); - await manager.init([page]); - - assert.notStrictEqual(manager.get(page), null); - }); - }); - - it('ignores pauses', async () => { - await withBrowser(async (browser, page) => { - const manager = new UniverseManager(browser); - await manager.init([page]); - const targetUniverse = manager.get(page); - assert.ok(targetUniverse); - const model = targetUniverse.target.model(DevTools.DebuggerModel); - assert.ok(model); - - const pausedSpy = sinon.stub(); - model.addEventListener('DebuggerPaused' as any, pausedSpy); // eslint-disable-line - - const result = await page.evaluate('debugger; 1 + 1'); - assert.strictEqual(result, 2); - - sinon.assert.notCalled(pausedSpy); - }); - }); -}); diff --git a/tests/McpContext.test.js.snapshot b/tests/McpContext.test.js.snapshot deleted file mode 100644 index b688da713..000000000 --- a/tests/McpContext.test.js.snapshot +++ /dev/null @@ -1,45 +0,0 @@ -exports[`McpContext > should include detailed network request in structured content 1`] = ` -{ - "networkRequest": { - "requestId": 456, - "method": "GET", - "url": "http://example.com/detail", - "status": "[pending]", - "requestHeaders": { - "content-size": "10" - } - } -} -`; - -exports[`McpContext > should include file paths in structured content when saving to file 1`] = ` -{ - "networkRequest": { - "requestBody": "/tmp/req.txt", - "responseBody": "/tmp/res.txt" - } -} -`; - -exports[`McpContext > should include network requests in structured content 1`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 1, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 123, - "method": "GET", - "url": "http://example.com/api", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts deleted file mode 100644 index e3683152d..000000000 --- a/tests/McpContext.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import sinon from 'sinon'; - -import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; -import type {HTTPResponse} from '../src/third_party/index.js'; -import type {TraceResult} from '../src/trace-processing/parse.js'; - -import {getMockRequest, html, withMcpContext} from './utils.js'; - -describe('McpContext', () => { - it('list pages', async () => { - await withMcpContext(async (_response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html` - `, - ); - await context.createTextSnapshot(); - assert.ok(await context.getElementByUid('1_1')); - await context.createTextSnapshot(); - await context.getElementByUid('1_1'); - }); - }); - - it('can store and retrieve the latest performance trace', async () => { - await withMcpContext(async (_response, context) => { - const fakeTrace1 = {} as unknown as TraceResult; - const fakeTrace2 = {} as unknown as TraceResult; - context.storeTraceRecording(fakeTrace1); - context.storeTraceRecording(fakeTrace2); - assert.deepEqual(context.recordedTraces(), [fakeTrace2]); - }); - }); - - it('should update default timeout when cpu throttling changes', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(); - const timeoutBefore = page.getDefaultTimeout(); - context.setCpuThrottlingRate(2); - const timeoutAfter = page.getDefaultTimeout(); - assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); - }); - }); - - it('should update default timeout when network conditions changes', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(); - const timeoutBefore = page.getDefaultNavigationTimeout(); - context.setNetworkConditions('Slow 3G'); - const timeoutAfter = page.getDefaultNavigationTimeout(); - assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); - }); - }); - - it('should call waitForEventsAfterAction with correct multipliers', async () => { - await withMcpContext(async (_response, context) => { - const page = await context.newPage(); - - context.setCpuThrottlingRate(2); - context.setNetworkConditions('Slow 3G'); - const stub = sinon.spy(context, 'getWaitForHelper'); - - await context.waitForEventsAfterAction(async () => { - // trigger the waiting only - }); - - sinon.assert.calledWithExactly(stub, page, 2, 10); - }); - }); - - it('should should detect open DevTools pages', async () => { - await withMcpContext( - async (_response, context) => { - const page = await context.newPage(); - // TODO: we do not know when the CLI flag to auto open DevTools will run - // so we need this until - // https://github.com/puppeteer/puppeteer/issues/14368 is there. - await new Promise(resolve => setTimeout(resolve, 5000)); - await context.createPagesSnapshot(); - assert.ok(context.getDevToolsPage(page)); - }, - { - autoOpenDevTools: true, - }, - ); - }); - it('should include network requests in structured content', async t => { - await withMcpContext(async (response, context) => { - const mockRequest = getMockRequest({ - url: 'http://example.com/api', - stableId: 123, - }); - - sinon.stub(context, 'getNetworkRequests').returns([mockRequest]); - sinon.stub(context, 'getNetworkRequestStableId').returns(123); - - response.setIncludeNetworkRequests(true); - const result = await response.handle('test', context); - - t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); - }); - }); - - it('should include detailed network request in structured content', async t => { - await withMcpContext(async (response, context) => { - const mockRequest = getMockRequest({ - url: 'http://example.com/detail', - stableId: 456, - }); - - sinon.stub(context, 'getNetworkRequestById').returns(mockRequest); - sinon.stub(context, 'getNetworkRequestStableId').returns(456); - - response.attachNetworkRequest(456); - const result = await response.handle('test', context); - - t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); - }); - }); - - it('should include file paths in structured content when saving to file', async t => { - await withMcpContext(async (response, context) => { - const mockRequest = getMockRequest({ - url: 'http://example.com/file-save', - stableId: 789, - hasPostData: true, - postData: 'some detailed data', - response: { - status: () => 200, - headers: () => ({'content-type': 'text/plain'}), - buffer: async () => Buffer.from('some response data'), - } as unknown as HTTPResponse, - }); - - sinon.stub(context, 'getNetworkRequestById').returns(mockRequest); - sinon.stub(context, 'getNetworkRequestStableId').returns(789); - - // We stub NetworkFormatter.from to avoid actual file system writes and verify arguments - const fromStub = sinon - .stub(NetworkFormatter, 'from') - .callsFake(async (_req, opts) => { - // Verify we received the file paths - assert.strictEqual(opts?.requestFilePath, '/tmp/req.txt'); - assert.strictEqual(opts?.responseFilePath, '/tmp/res.txt'); - // Return a dummy formatter that behaves as if it saved files - // We need to create a real instance or mock one. - // Since constructor is private, we can't easily new it up. - // But we can return a mock object. - return { - toStringDetailed: () => 'Detailed string', - toJSONDetailed: () => ({ - requestBody: '/tmp/req.txt', - responseBody: '/tmp/res.txt', - }), - } as unknown as NetworkFormatter; - }); - - response.attachNetworkRequest(789, { - requestFilePath: '/tmp/req.txt', - responseFilePath: '/tmp/res.txt', - }); - const result = await response.handle('test', context); - - t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); - - fromStub.restore(); - }); - }); -}); diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot deleted file mode 100644 index e11f94941..000000000 --- a/tests/McpResponse.test.js.snapshot +++ /dev/null @@ -1,1475 +0,0 @@ -exports[`McpResponse > add network request when attached 1`] = ` -# test response -## Request http://example.com -Status: [pending] -### Request Headers -- content-size:10 -## Network requests -Showing 1-1 of 1 (Page 1 of 1). -reqid=1 GET http://example.com [pending] -`; - -exports[`McpResponse > add network request when attached 2`] = ` -{ - "networkRequest": { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "requestHeaders": { - "content-size": "10" - } - }, - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 1, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse > add network request when attached with POST data 1`] = ` -# test response -## Request http://example.com -Status: [success - 200] -### Request Headers -- content-size:10 -### Request Body -{"request":"body"} -### Response Headers -- Content-Type:application/json -### Response Body -{"response":"body"} -## Network requests -Showing 1-1 of 1 (Page 1 of 1). -reqid=1 POST http://example.com [success - 200] -`; - -exports[`McpResponse > add network request when attached with POST data 2`] = ` -{ - "networkRequest": { - "requestId": 1, - "method": "POST", - "url": "http://example.com", - "status": "[success - 200]", - "requestHeaders": { - "content-size": "10" - }, - "requestBody": "{\\"request\\":\\"body\\"}", - "responseHeaders": { - "Content-Type": "application/json" - }, - "responseBody": "{\\"response\\":\\"body\\"}" - }, - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 1, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "POST", - "url": "http://example.com", - "status": "[success - 200]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse > add network requests when setting is true 1`] = ` -# test response -## Network requests -Showing 1-2 of 2 (Page 1 of 1). -reqid=1 GET http://example.com [pending] -reqid=2 GET http://example.com [pending] -`; - -exports[`McpResponse > add network requests when setting is true 2`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 2, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 2, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse > adds a message when no console messages exist 1`] = ` -# test response -## Console messages - -`; - -exports[`McpResponse > adds a message when no console messages exist 2`] = ` -{} -`; - -exports[`McpResponse > adds a prompt dialog 1`] = ` -# test response -# Open dialog -prompt: message (default value: "default"). -Call handle_dialog to handle it before continuing. -`; - -exports[`McpResponse > adds a prompt dialog 2`] = ` -{ - "dialog": { - "type": "prompt", - "message": "message", - "defaultValue": "default" - } -} -`; - -exports[`McpResponse > adds an alert dialog 1`] = ` -# test response -# Open dialog -alert: message. -Call handle_dialog to handle it before continuing. -`; - -exports[`McpResponse > adds an alert dialog 2`] = ` -{ - "dialog": { - "type": "alert", - "message": "message", - "defaultValue": "" - } -} -`; - -exports[`McpResponse > adds color scheme emulation setting when it is set 1`] = ` -# test response -## Color Scheme emulation -Emulating: dark -`; - -exports[`McpResponse > adds color scheme emulation setting when it is set 2`] = ` -{ - "colorScheme": "dark" -} -`; - -exports[`McpResponse > adds console messages when the setting is true 1`] = ` -# test response -## Console messages -Showing 1-1 of 1 (Page 1 of 1). -msgid=1 [log] Hello from the test (1 args) -`; - -exports[`McpResponse > adds console messages when the setting is true 2`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 1, - "invalidPage": false - }, - "consoleMessages": [ - { - "type": "log", - "text": "Hello from the test", - "argsCount": 1, - "id": 1 - } - ] -} -`; - -exports[`McpResponse > adds cpu throttling setting when it is over 1 1`] = ` -# test response -## CPU emulation -Emulating: 4x slowdown -`; - -exports[`McpResponse > adds cpu throttling setting when it is over 1 2`] = ` -{ - "cpuThrottlingRate": 4 -} -`; - -exports[`McpResponse > adds image when image is attached 1`] = ` -{} -`; - -exports[`McpResponse > adds throttling setting when it is not null 1`] = ` -# test response -## Network emulation -Emulating: Slow 3G -Default navigation timeout set to 100000 ms -`; - -exports[`McpResponse > adds throttling setting when it is not null 2`] = ` -{ - "networkConditions": "Slow 3G", - "navigationTimeout": 100000 -} -`; - -exports[`McpResponse > adds userAgent emulation setting when it is set 1`] = ` -# test response -## UserAgent emulation -Emulating userAgent: MyUA -`; - -exports[`McpResponse > adds userAgent emulation setting when it is set 2`] = ` -{ - "userAgent": "MyUA" -} -`; - -exports[`McpResponse > adds viewport emulation setting when it is set 1`] = ` -# test response -## Viewport emulation -Emulating viewport: {"width":400,"height":400,"deviceScaleFactor":1} -`; - -exports[`McpResponse > adds viewport emulation setting when it is set 2`] = ` -{ - "viewport": { - "width": 400, - "height": 400, - "deviceScaleFactor": 1 - } -} -`; - -exports[`McpResponse > allows response text lines to be added 1`] = ` -# test response -Testing 1 -Testing 2 -`; - -exports[`McpResponse > allows response text lines to be added 2`] = ` -{ - "message": "Testing 1\\nTesting 2" -} -`; - -exports[`McpResponse > does not include anything in response if snapshot is null 1`] = ` -{} -`; - -exports[`McpResponse > does not include cpu throttling setting when it is 1 1`] = ` -{} -`; - -exports[`McpResponse > does not include network requests when setting is false 1`] = ` -{} -`; - -exports[`McpResponse > does not include throttling setting when it is null 1`] = ` -{} -`; - -exports[`McpResponse > doesn't list the issue message if mapping returns null 1`] = ` -{} -`; - -exports[`McpResponse > list pages 1`] = ` -# test response -## Pages -1: about:blank [selected] -`; - -exports[`McpResponse > list pages 2`] = ` -{ - "pages": [ - { - "id": 1, - "url": "about:blank", - "selected": true - } - ] -} -`; - -exports[`McpResponse > returns correctly formatted snapshot for a simple tree 1`] = ` -# test response -## Latest page snapshot -uid=1_0 RootWebArea "My test page" url="about:blank" - uid=1_1 button "Click me" focusable focused - uid=1_2 textbox value="Input" - -`; - -exports[`McpResponse > returns correctly formatted snapshot for a simple tree 2`] = ` -{ - "snapshot": { - "id": "1_0", - "role": "RootWebArea", - "name": "My test page", - "url": "about:blank", - "children": [ - { - "id": "1_1", - "role": "button", - "name": "Click me", - "focusable": true, - "focused": true - }, - { - "id": "1_2", - "role": "textbox", - "value": "Input" - } - ] - } -} -`; - -exports[`McpResponse > returns values for textboxes 1`] = ` -# test response -## Latest page snapshot -uid=1_0 RootWebArea "My test page" url="about:blank" - uid=1_1 StaticText "username" - uid=1_2 textbox "username" focusable focused value="mcp" - -`; - -exports[`McpResponse > returns values for textboxes 2`] = ` -{ - "snapshot": { - "id": "1_0", - "role": "RootWebArea", - "name": "My test page", - "url": "about:blank", - "children": [ - { - "id": "1_1", - "role": "StaticText", - "name": "username" - }, - { - "id": "1_2", - "role": "textbox", - "name": "username", - "focusable": true, - "focused": true, - "value": "mcp" - } - ] - } -} -`; - -exports[`McpResponse > returns verbose snapshot and structured content 1`] = ` -# test response -## Latest page snapshot -uid=1_0 RootWebArea "My test page" url="about:blank" - uid=1_1 ignored - uid=1_2 ignored - uid=1_3 complementary - uid=1_4 StaticText "test" - uid=1_5 InlineTextBox "test" - -`; - -exports[`McpResponse > returns verbose snapshot and structured content 2`] = ` -{ - "snapshot": { - "id": "1_0", - "role": "RootWebArea", - "name": "My test page", - "url": "about:blank", - "children": [ - { - "id": "1_1", - "role": "none", - "children": [ - { - "id": "1_2", - "role": "none", - "children": [ - { - "id": "1_3", - "role": "complementary", - "children": [ - { - "id": "1_4", - "role": "StaticText", - "name": "test", - "children": [ - { - "id": "1_5", - "role": "InlineTextBox", - "name": "test" - } - ] - } - ] - } - ] - } - ] - } - ] - } -} -`; - -exports[`McpResponse > saves snapshot to file and returns structured content 1`] = ` -# test response -Saved snapshot to -`; - -exports[`McpResponse > saves snapshot to file and returns structured content 2`] = ` -{ - "snapshotFilePath": "" -} -`; - -exports[`McpResponse > saves snapshot to file and returns structured content 3`] = ` -uid=1_0 RootWebArea "My test page" url="about:blank" - uid=1_1 ignored - uid=1_2 ignored - uid=1_3 complementary - uid=1_4 StaticText "test" - uid=1_5 InlineTextBox "test" - -`; - -exports[`McpResponse network pagination > handles invalid page number by showing first page 1`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 3, - "hasNextPage": true, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 2, - "invalidPage": true - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network pagination > returns all requests when pagination is not provided 1`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 5, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network pagination > returns first page by default 1`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 3, - "hasNextPage": true, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 10, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET-0", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-1", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-2", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-3", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-4", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-5", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-6", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-7", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-8", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-9", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-10", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-11", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-12", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-13", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-14", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-15", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-16", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-17", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-18", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-19", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-20", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-21", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-22", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-23", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-24", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-25", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-26", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-27", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-28", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-29", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network pagination > returns subsequent page when pageIdx provided 1`] = ` -{ - "pagination": { - "currentPage": 1, - "totalPages": 3, - "hasNextPage": true, - "hasPreviousPage": true, - "startIndex": 10, - "endIndex": 20, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET-0", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-1", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-2", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-3", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-4", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-5", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-6", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-7", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-8", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-9", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-10", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-11", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-12", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-13", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-14", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-15", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-16", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-17", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-18", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-19", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-20", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-21", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-22", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-23", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET-24", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network pagination > trace insights > includes error if insight not found 1`] = ` -# test response -No Performance Insights for the given insight set id. Only use ids given in the "Available insight sets" list. -`; - -exports[`McpResponse network pagination > trace insights > includes error if insight not found 2`] = ` -{} -`; - -exports[`McpResponse network pagination > trace insights > includes the trace insight output 1`] = ` -# test response -## Insight Title: LCP breakdown - -## Insight Summary: -This insight is used to analyze the time spent that contributed to the final LCP time and identify which of the 4 phases (or 2 if there was no LCP resource) are contributing most to the delay in rendering the LCP element. - -## Detailed analysis: -The Largest Contentful Paint (LCP) time for this navigation was 129 ms. -The LCP element is an image fetched from https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg (eventKey: s-1314, ts: 122411037986). -## LCP resource network request: https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg -eventKey: s-1314 -Timings: -- Queued at: 41 ms -- Request sent at: 47 ms -- Download complete at: 56 ms -- Main thread processing completed at: 58 ms -Durations: -- Download time: 0.3 ms -- Main thread processing time: 2 ms -- Total duration: 17 ms -Redirects: no redirects -Status code: 200 -MIME Type: image/svg+xml -Protocol: unknown -Priority: VeryHigh -Render blocking: No -From a service worker: No -Initiators (root request to the request that directly loaded this one): none - - -We can break this time down into the 4 phases that combine to make the LCP time: - -- Time to first byte: 8 ms (6.1% of total LCP time) -- Resource load delay: 33 ms (25.7% of total LCP time) -- Resource load duration: 15 ms (11.4% of total LCP time) -- Element render delay: 73 ms (56.8% of total LCP time) - -## Estimated savings: none - -## External resources: -- https://developer.chrome.com/docs/performance/insights/lcp-breakdown -- https://web.dev/articles/lcp -- https://web.dev/articles/optimize-lcp -`; - -exports[`McpResponse network pagination > trace insights > includes the trace insight output 2`] = ` -{} -`; - -exports[`McpResponse network pagination > trace summaries > includes the trace summary text and structured data 1`] = ` -# test response -## Summary of Performance trace findings: -URL: https://web.dev/ -Trace bounds: {min: 122410994891, max: 122416385853} -CPU throttling: none -Network throttling: none - -# Available insight sets - -The following is a list of insight sets. An insight set covers a specific part of the trace, split by navigations. The insights within each insight set are specific to that part of the trace. Be sure to consider the insight set id and bounds when calling functions. If no specific insight set or navigation is mentioned, assume the user is referring to the first one. - -## insight set id: NAVIGATION_0 - -URL: https://web.dev/ -Bounds: {min: 122410996889, max: 122416385853} -Metrics (lab / observed): - - LCP: 129 ms, event: (eventKey: r-6063, ts: 122411126100), nodeId: 7 - - LCP breakdown: - - TTFB: 8 ms, bounds: {min: 122410996889, max: 122411004828} - - Load delay: 33 ms, bounds: {min: 122411004828, max: 122411037986} - - Load duration: 15 ms, bounds: {min: 122411037986, max: 122411052690} - - Render delay: 73 ms, bounds: {min: 122411052690, max: 122411126100} - - CLS: 0.00 -Metrics (field / real users): n/a – no data for this page in CrUX -Available insights: - - insight name: LCPBreakdown - description: Each [subpart has specific improvement strategies](https://developer.chrome.com/docs/performance/insights/lcp-breakdown). Ideally, most of the LCP time should be spent on loading the resources, not within delays. - relevant trace bounds: {min: 122410996889, max: 122411126100} - example question: Help me optimize my LCP score - example question: Which LCP phase was most problematic? - example question: What can I do to reduce the LCP time for this page load? - - insight name: LCPDiscovery - description: [Optimize LCP](https://developer.chrome.com/docs/performance/insights/lcp-discovery) by making the LCP image discoverable from the HTML immediately, and avoiding lazy-loading - relevant trace bounds: {min: 122411004828, max: 122411055039} - example question: Suggest fixes to reduce my LCP - example question: What can I do to reduce my LCP discovery time? - example question: Why is LCP discovery time important? - - insight name: RenderBlocking - description: Requests are blocking the page's initial render, which may delay LCP. [Deferring or inlining](https://developer.chrome.com/docs/performance/insights/render-blocking) can move these network requests out of the critical path. - relevant trace bounds: {min: 122411037528, max: 122411053852} - example question: Show me the most impactful render blocking requests that I should focus on - example question: How can I reduce the number of render blocking requests? - - insight name: DocumentLatency - description: Your first network request is the most important. [Reduce its latency](https://developer.chrome.com/docs/performance/insights/document-latency) by avoiding redirects, ensuring a fast server response, and enabling text compression. - relevant trace bounds: {min: 122410998910, max: 122411043781} - estimated metric savings: FCP 0 ms, LCP 0 ms - estimated wasted bytes: 77.1 kB - example question: How do I decrease the initial loading time of my page? - example question: Did anything slow down the request for this document? - - insight name: ThirdParties - description: 3rd party code can significantly impact load performance. [Reduce and defer loading of 3rd party code](https://developer.chrome.com/docs/performance/insights/third-parties) to prioritize your page's content. - relevant trace bounds: {min: 122411037881, max: 122416229595} - example question: Which third parties are having the largest impact on my page performance? - -## Details on call tree & network request formats: -Information on performance traces may contain main thread activity represented as call frames and network requests. - -Each call frame is presented in the following format: - -'id;eventKey;name;duration;selfTime;urlIndex;childRange;[line];[column];[S]' - -Key definitions: - -* id: A unique numerical identifier for the call frame. Never mention this id in the output to the user. -* eventKey: String that uniquely identifies this event in the flame chart. -* name: A concise string describing the call frame (e.g., 'Evaluate Script', 'render', 'fetchData'). -* duration: The total execution time of the call frame, including its children. -* selfTime: The time spent directly within the call frame, excluding its children's execution. -* urlIndex: Index referencing the "All URLs" list. Empty if no specific script URL is associated. -* childRange: Specifies the direct children of this node using their IDs. If empty ('' or 'S' at the end), the node has no children. If a single number (e.g., '4'), the node has one child with that ID. If in the format 'firstId-lastId' (e.g., '4-5'), it indicates a consecutive range of child IDs from 'firstId' to 'lastId', inclusive. -* line: An optional field for a call frame's line number. This is where the function is defined. -* column: An optional field for a call frame's column number. This is where the function is defined. -* S: _Optional_. The letter 'S' terminates the line if that call frame was selected by the user. - -Example Call Tree: - -1;r-123;main;500;100;0;1;; -2;r-124;update;200;50;;3;0;1; -3;p-49575-15428179-2834-374;animate;150;20;0;4-5;0;1;S -4;p-49575-15428179-3505-1162;calculatePosition;80;80;0;1;; -5;p-49575-15428179-5391-2767;applyStyles;50;50;0;1;; - - -Network requests are formatted like this: -\`urlIndex;eventKey;queuedTime;requestSentTime;downloadCompleteTime;processingCompleteTime;totalDuration;downloadDuration;mainThreadProcessingDuration;statusCode;mimeType;priority;initialPriority;finalPriority;renderBlocking;protocol;fromServiceWorker;initiators;redirects:[[redirectUrlIndex|startTime|duration]];responseHeaders:[header1Value|header2Value|...]\` - -- \`urlIndex\`: Numerical index for the request's URL, referencing the "All URLs" list. -- \`eventKey\`: String that uniquely identifies this request's trace event. -Timings (all in milliseconds, relative to navigation start): -- \`queuedTime\`: When the request was queued. -- \`requestSentTime\`: When the request was sent. -- \`downloadCompleteTime\`: When the download completed. -- \`processingCompleteTime\`: When main thread processing finished. -Durations (all in milliseconds): -- \`totalDuration\`: Total time from the request being queued until its main thread processing completed. -- \`downloadDuration\`: Time spent actively downloading the resource. -- \`mainThreadProcessingDuration\`: Time spent on the main thread after the download completed. -- \`statusCode\`: The HTTP status code of the response (e.g., 200, 404). -- \`mimeType\`: The MIME type of the resource (e.g., "text/html", "application/javascript"). -- \`priority\`: The final network request priority (e.g., "VeryHigh", "Low"). -- \`initialPriority\`: The initial network request priority. -- \`finalPriority\`: The final network request priority (redundant if \`priority\` is always final, but kept for clarity if \`initialPriority\` and \`priority\` differ). -- \`renderBlocking\`: 't' if the request was render-blocking, 'f' otherwise. -- \`protocol\`: The network protocol used (e.g., "h2", "http/1.1"). -- \`fromServiceWorker\`: 't' if the request was served from a service worker, 'f' otherwise. -- \`initiators\`: A list (separated by ,) of URL indices for the initiator chain of this request. Listed in order starting from the root request to the request that directly loaded this one. This represents the network dependencies necessary to load this request. If there is no initiator, this is empty. -- \`redirects\`: A comma-separated list of redirects, enclosed in square brackets. Each redirect is formatted as -\`[redirectUrlIndex|startTime|duration]\`, where: \`redirectUrlIndex\`: Numerical index for the redirect's URL. \`startTime\`: The start time of the redirect in milliseconds, relative to navigation start. \`duration\`: The duration of the redirect in milliseconds. -- \`responseHeaders\`: A list (separated by '|') of values for specific, pre-defined response headers, enclosed in square brackets. -The order of headers corresponds to an internal fixed list. If a header is not present, its value will be empty. - -`; - -exports[`McpResponse network pagination > trace summaries > includes the trace summary text and structured data 2`] = ` -"## Summary of Performance trace findings:\\nURL: https://web.dev/\\nTrace bounds: {min: 122410994891, max: 122416385853}\\nCPU throttling: none\\nNetwork throttling: none\\n\\n# Available insight sets\\n\\nThe following is a list of insight sets. An insight set covers a specific part of the trace, split by navigations. The insights within each insight set are specific to that part of the trace. Be sure to consider the insight set id and bounds when calling functions. If no specific insight set or navigation is mentioned, assume the user is referring to the first one.\\n\\n## insight set id: NAVIGATION_0\\n\\nURL: https://web.dev/\\nBounds: {min: 122410996889, max: 122416385853}\\nMetrics (lab / observed):\\n - LCP: 129 ms, event: (eventKey: r-6063, ts: 122411126100), nodeId: 7\\n - LCP breakdown:\\n - TTFB: 8 ms, bounds: {min: 122410996889, max: 122411004828}\\n - Load delay: 33 ms, bounds: {min: 122411004828, max: 122411037986}\\n - Load duration: 15 ms, bounds: {min: 122411037986, max: 122411052690}\\n - Render delay: 73 ms, bounds: {min: 122411052690, max: 122411126100}\\n - CLS: 0.00\\nMetrics (field / real users): n/a – no data for this page in CrUX\\nAvailable insights:\\n - insight name: LCPBreakdown\\n description: Each [subpart has specific improvement strategies](https://developer.chrome.com/docs/performance/insights/lcp-breakdown). Ideally, most of the LCP time should be spent on loading the resources, not within delays.\\n relevant trace bounds: {min: 122410996889, max: 122411126100}\\n example question: Help me optimize my LCP score\\n example question: Which LCP phase was most problematic?\\n example question: What can I do to reduce the LCP time for this page load?\\n - insight name: LCPDiscovery\\n description: [Optimize LCP](https://developer.chrome.com/docs/performance/insights/lcp-discovery) by making the LCP image discoverable from the HTML immediately, and avoiding lazy-loading\\n relevant trace bounds: {min: 122411004828, max: 122411055039}\\n example question: Suggest fixes to reduce my LCP\\n example question: What can I do to reduce my LCP discovery time?\\n example question: Why is LCP discovery time important?\\n - insight name: RenderBlocking\\n description: Requests are blocking the page's initial render, which may delay LCP. [Deferring or inlining](https://developer.chrome.com/docs/performance/insights/render-blocking) can move these network requests out of the critical path.\\n relevant trace bounds: {min: 122411037528, max: 122411053852}\\n example question: Show me the most impactful render blocking requests that I should focus on\\n example question: How can I reduce the number of render blocking requests?\\n - insight name: DocumentLatency\\n description: Your first network request is the most important. [Reduce its latency](https://developer.chrome.com/docs/performance/insights/document-latency) by avoiding redirects, ensuring a fast server response, and enabling text compression.\\n relevant trace bounds: {min: 122410998910, max: 122411043781}\\n estimated metric savings: FCP 0 ms, LCP 0 ms\\n estimated wasted bytes: 77.1 kB\\n example question: How do I decrease the initial loading time of my page?\\n example question: Did anything slow down the request for this document?\\n - insight name: ThirdParties\\n description: 3rd party code can significantly impact load performance. [Reduce and defer loading of 3rd party code](https://developer.chrome.com/docs/performance/insights/third-parties) to prioritize your page's content.\\n relevant trace bounds: {min: 122411037881, max: 122416229595}\\n example question: Which third parties are having the largest impact on my page performance?\\n\\n## Details on call tree & network request formats:\\nInformation on performance traces may contain main thread activity represented as call frames and network requests.\\n\\nEach call frame is presented in the following format:\\n\\n'id;eventKey;name;duration;selfTime;urlIndex;childRange;[line];[column];[S]'\\n\\nKey definitions:\\n\\n* id: A unique numerical identifier for the call frame. Never mention this id in the output to the user.\\n* eventKey: String that uniquely identifies this event in the flame chart.\\n* name: A concise string describing the call frame (e.g., 'Evaluate Script', 'render', 'fetchData').\\n* duration: The total execution time of the call frame, including its children.\\n* selfTime: The time spent directly within the call frame, excluding its children's execution.\\n* urlIndex: Index referencing the \\"All URLs\\" list. Empty if no specific script URL is associated.\\n* childRange: Specifies the direct children of this node using their IDs. If empty ('' or 'S' at the end), the node has no children. If a single number (e.g., '4'), the node has one child with that ID. If in the format 'firstId-lastId' (e.g., '4-5'), it indicates a consecutive range of child IDs from 'firstId' to 'lastId', inclusive.\\n* line: An optional field for a call frame's line number. This is where the function is defined.\\n* column: An optional field for a call frame's column number. This is where the function is defined.\\n* S: _Optional_. The letter 'S' terminates the line if that call frame was selected by the user.\\n\\nExample Call Tree:\\n\\n1;r-123;main;500;100;0;1;;\\n2;r-124;update;200;50;;3;0;1;\\n3;p-49575-15428179-2834-374;animate;150;20;0;4-5;0;1;S\\n4;p-49575-15428179-3505-1162;calculatePosition;80;80;0;1;;\\n5;p-49575-15428179-5391-2767;applyStyles;50;50;0;1;;\\n\\n\\nNetwork requests are formatted like this:\\n\`urlIndex;eventKey;queuedTime;requestSentTime;downloadCompleteTime;processingCompleteTime;totalDuration;downloadDuration;mainThreadProcessingDuration;statusCode;mimeType;priority;initialPriority;finalPriority;renderBlocking;protocol;fromServiceWorker;initiators;redirects:[[redirectUrlIndex|startTime|duration]];responseHeaders:[header1Value|header2Value|...]\`\\n\\n- \`urlIndex\`: Numerical index for the request's URL, referencing the \\"All URLs\\" list.\\n- \`eventKey\`: String that uniquely identifies this request's trace event.\\nTimings (all in milliseconds, relative to navigation start):\\n- \`queuedTime\`: When the request was queued.\\n- \`requestSentTime\`: When the request was sent.\\n- \`downloadCompleteTime\`: When the download completed.\\n- \`processingCompleteTime\`: When main thread processing finished.\\nDurations (all in milliseconds):\\n- \`totalDuration\`: Total time from the request being queued until its main thread processing completed.\\n- \`downloadDuration\`: Time spent actively downloading the resource.\\n- \`mainThreadProcessingDuration\`: Time spent on the main thread after the download completed.\\n- \`statusCode\`: The HTTP status code of the response (e.g., 200, 404).\\n- \`mimeType\`: The MIME type of the resource (e.g., \\"text/html\\", \\"application/javascript\\").\\n- \`priority\`: The final network request priority (e.g., \\"VeryHigh\\", \\"Low\\").\\n- \`initialPriority\`: The initial network request priority.\\n- \`finalPriority\`: The final network request priority (redundant if \`priority\` is always final, but kept for clarity if \`initialPriority\` and \`priority\` differ).\\n- \`renderBlocking\`: 't' if the request was render-blocking, 'f' otherwise.\\n- \`protocol\`: The network protocol used (e.g., \\"h2\\", \\"http/1.1\\").\\n- \`fromServiceWorker\`: 't' if the request was served from a service worker, 'f' otherwise.\\n- \`initiators\`: A list (separated by ,) of URL indices for the initiator chain of this request. Listed in order starting from the root request to the request that directly loaded this one. This represents the network dependencies necessary to load this request. If there is no initiator, this is empty.\\n- \`redirects\`: A comma-separated list of redirects, enclosed in square brackets. Each redirect is formatted as\\n\`[redirectUrlIndex|startTime|duration]\`, where: \`redirectUrlIndex\`: Numerical index for the redirect's URL. \`startTime\`: The start time of the redirect in milliseconds, relative to navigation start. \`duration\`: The duration of the redirect in milliseconds.\\n- \`responseHeaders\`: A list (separated by '|') of values for specific, pre-defined response headers, enclosed in square brackets.\\nThe order of headers corresponds to an internal fixed list. If a header is not present, its value will be empty.\\n" -`; - -exports[`McpResponse network pagination > trace summaries > includes the trace summary text and structured data 3`] = ` -[ - { - "insightName": "INPBreakdown", - "insightKey": "INPBreakdown" - }, - { - "insightName": "LCPBreakdown", - "insightKey": "LCPBreakdown" - }, - { - "insightName": "LCPDiscovery", - "insightKey": "LCPDiscovery" - }, - { - "insightName": "CLSCulprits", - "insightKey": "CLSCulprits" - }, - { - "insightName": "RenderBlocking", - "insightKey": "RenderBlocking" - }, - { - "insightName": "NetworkDependencyTree", - "insightKey": "NetworkDependencyTree" - }, - { - "insightName": "ImageDelivery", - "insightKey": "ImageDelivery" - }, - { - "insightName": "DocumentLatency", - "insightKey": "DocumentLatency" - }, - { - "insightName": "FontDisplay", - "insightKey": "FontDisplay" - }, - { - "insightName": "Viewport", - "insightKey": "Viewport" - }, - { - "insightName": "DOMSize", - "insightKey": "DOMSize" - }, - { - "insightName": "ThirdParties", - "insightKey": "ThirdParties" - }, - { - "insightName": "DuplicatedJavaScript", - "insightKey": "DuplicatedJavaScript" - }, - { - "insightName": "SlowCSSSelector", - "insightKey": "SlowCSSSelector" - }, - { - "insightName": "ForcedReflow", - "insightKey": "ForcedReflow" - }, - { - "insightName": "Cache", - "insightKey": "Cache" - }, - { - "insightName": "ModernHTTP", - "insightKey": "ModernHTTP" - }, - { - "insightName": "LegacyJavaScript", - "insightKey": "LegacyJavaScript" - } -] -`; - -exports[`McpResponse network request filtering > filters network requests by resource type 1`] = ` -# test response -## Network requests -Showing 1-2 of 2 (Page 1 of 1). -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -`; - -exports[`McpResponse network request filtering > filters network requests by resource type 2`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 2, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network request filtering > filters network requests by single resource type 1`] = ` -# test response -## Network requests -Showing 1-1 of 1 (Page 1 of 1). -reqid=1 GET http://example.com [pending] -`; - -exports[`McpResponse network request filtering > filters network requests by single resource type 2`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 1, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network request filtering > shows all requests when empty resourceTypes array is provided 1`] = ` -# test response -## Network requests -Showing 1-5 of 5 (Page 1 of 1). -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -`; - -exports[`McpResponse network request filtering > shows all requests when empty resourceTypes array is provided 2`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 5, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network request filtering > shows all requests when no filters are provided 1`] = ` -# test response -## Network requests -Showing 1-5 of 5 (Page 1 of 1). -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -reqid=1 GET http://example.com [pending] -`; - -exports[`McpResponse network request filtering > shows all requests when no filters are provided 2`] = ` -{ - "pagination": { - "currentPage": 0, - "totalPages": 1, - "hasNextPage": false, - "hasPreviousPage": false, - "startIndex": 0, - "endIndex": 5, - "invalidPage": false - }, - "networkRequests": [ - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - }, - { - "requestId": 1, - "method": "GET", - "url": "http://example.com", - "status": "[pending]", - "selectedInDevToolsUI": false - } - ] -} -`; - -exports[`McpResponse network request filtering > shows no requests when filter matches nothing 1`] = ` -# test response -## Network requests -No requests found. -`; - -exports[`McpResponse network request filtering > shows no requests when filter matches nothing 2`] = ` -{} -`; - -exports[`extensions > lists extensions 1`] = ` -# test response -## Extensions -id=id1 "Extension 1" v1.0 Enabled -id=id2 "Extension 2" v2.0 Disabled -`; - -exports[`extensions > lists extensions 2`] = ` -{ - "extensions": [ - { - "id": "id1", - "name": "Extension 1", - "version": "1.0", - "isEnabled": true, - "path": "/path/to/ext1" - }, - { - "id": "id2", - "name": "Extension 2", - "version": "2.0", - "isEnabled": false, - "path": "/path/to/ext2" - } - ] -} -`; diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts deleted file mode 100644 index 63b0955b6..000000000 --- a/tests/McpResponse.test.ts +++ /dev/null @@ -1,971 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {readFile, rm} from 'node:fs/promises'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; -import {describe, it} from 'node:test'; - -import type {InsightName} from '../src/trace-processing/parse.js'; -import { - parseRawTraceBuffer, - traceResultIsSuccess, -} from '../src/trace-processing/parse.js'; - -import {serverHooks} from './server.js'; -import {loadTraceAsBuffer} from './trace-processing/fixtures/load.js'; -import { - getImageContent, - getMockAggregatedIssue, - getMockRequest, - getMockResponse, - getTextContent, - html, - stabilizeResponseOutput, - stabilizeStructuredContent, - withMcpContext, -} from './utils.js'; - -describe('McpResponse', () => { - it('list pages', async t => { - await withMcpContext(async (response, context) => { - response.setIncludePages(true); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('allows response text lines to be added', async t => { - await withMcpContext(async (response, context) => { - response.appendResponseLine('Testing 1'); - response.appendResponseLine('Testing 2'); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('does not include anything in response if snapshot is null', async t => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - page.accessibility.snapshot = async () => null; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - assert.deepStrictEqual(getTextContent(content[0]), `# test response`); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('returns correctly formatted snapshot for a simple tree', async t => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html` - `, - ); - await page.focus('button'); - response.includeSnapshot(); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('returns values for textboxes', async t => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await page.focus('input'); - response.includeSnapshot(); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('returns verbose snapshot and structured content', async t => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent(html``); - response.includeSnapshot({ - verbose: true, - }); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); - }); - }); - - it('saves snapshot to file and returns structured content', async t => { - const filePath = join(tmpdir(), 'test-screenshot.png'); - try { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent(html``); - response.includeSnapshot({ - verbose: true, - filePath, - }); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.( - stabilizeResponseOutput(getTextContent(content[0])), - ); - t.assert.snapshot?.( - JSON.stringify( - stabilizeStructuredContent(structuredContent), - null, - 2, - ), - ); - }); - const content = await readFile(filePath, 'utf-8'); - t.assert.snapshot?.(stabilizeResponseOutput(content)); - } finally { - await rm(filePath, {force: true}); - } - }); - - it('preserves mapping ids across multiple snapshots', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent(html` -
- - Span 1 -
- `); - response.includeSnapshot(); - // First snapshot - const res1 = await response.handle('test', context); - const text1 = getTextContent(res1.content[0]); - const btn1IdMatch = text1.match(/uid=(\S+) .*Button 1/); - const span1IdMatch = text1.match(/uid=(\S+) .*Span 1/); - - assert.ok(btn1IdMatch, 'Button 1 ID not found in first snapshot'); - assert.ok(span1IdMatch, 'Span 1 ID not found in first snapshot'); - - const btn1Id = btn1IdMatch[1]; - const span1Id = span1IdMatch[1]; - - // Modify page: add a new element before the others to potentially shift indices if not stable - await page.evaluate(() => { - const newBtn = document.createElement('button'); - newBtn.textContent = 'Button 2'; - document.body.prepend(newBtn); - }); - - // Second snapshot - const res2 = await response.handle('test', context); - const text2 = getTextContent(res2.content[0]); - - const btn1IdMatch2 = text2.match(/uid=(\S+) .*Button 1/); - const span1IdMatch2 = text2.match(/uid=(\S+) .*Span 1/); - const btn2IdMatch = text2.match(/uid=(\S+) .*Button 2/); - - assert.ok(btn1IdMatch2, 'Button 1 ID not found in second snapshot'); - assert.ok(span1IdMatch2, 'Span 1 ID not found in second snapshot'); - assert.ok(btn2IdMatch, 'Button 2 ID not found in second snapshot'); - - assert.strictEqual( - btn1IdMatch2[1], - btn1Id, - 'Button 1 ID changed between snapshots', - ); - assert.strictEqual( - span1IdMatch2[1], - span1Id, - 'Span 1 ID changed between snapshots', - ); - assert.notStrictEqual( - btn2IdMatch[1], - btn1Id, - 'Button 2 ID collides with Button 1', - ); - assert.notStrictEqual( - btn2IdMatch[1], - btn1Id, - 'Button 2 ID collides with Button 1', - ); - }); - }); - - describe('navigation', () => { - const server = serverHooks(); - - it('resets ids after navigation', async () => { - await withMcpContext(async (response, context) => { - server.addHtmlRoute( - '/page.html', - html` -
- -
- `, - ); - const page = context.getSelectedPage(); - await page.goto(server.getRoute('/page.html')); - - response.includeSnapshot(); - const res1 = await response.handle('test', context); - const text1 = getTextContent(res1.content[0]); - const btn1IdMatch = text1.match(/uid=(\S+) .*Button 1/); - assert.ok(btn1IdMatch, 'Button 1 ID not found in first snapshot'); - const btn1Id = btn1IdMatch[1]; - - // Navigate to the same page again (or meaningful navigation) - await page.goto(server.getRoute('/page.html')); - - const res2 = await response.handle('test', context); - const text2 = getTextContent(res2.content[0]); - const btn1IdMatch2 = text2.match(/uid=(\S+) .*Button 1/); - assert.ok(btn1IdMatch2, 'Button 1 ID not found in second snapshot'); - const btn1Id2 = btn1IdMatch2[1]; - - assert.notStrictEqual( - btn1Id2, - btn1Id, - 'ID should reset after navigation', - ); - }); - }); - }); - - it('adds throttling setting when it is not null', async t => { - await withMcpContext(async (response, context) => { - context.setNetworkConditions('Slow 3G'); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('does not include throttling setting when it is null', async t => { - await withMcpContext(async (response, context) => { - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - context.setNetworkConditions(null); - assert.equal(content[0].type, 'text'); - assert.strictEqual(getTextContent(content[0]), `# test response`); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - it('adds image when image is attached', async t => { - await withMcpContext(async (response, context) => { - response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.strictEqual(getTextContent(content[0]), `# test response`); - assert.equal(content[1].type, 'image'); - assert.strictEqual(getImageContent(content[1]).data, 'imageBase64'); - assert.strictEqual(getImageContent(content[1]).mimeType, 'image/png'); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds cpu throttling setting when it is over 1', async t => { - await withMcpContext(async (response, context) => { - context.setCpuThrottlingRate(4); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('does not include cpu throttling setting when it is 1', async t => { - await withMcpContext(async (response, context) => { - context.setCpuThrottlingRate(1); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.strictEqual(getTextContent(content[0]), `# test response`); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds viewport emulation setting when it is set', async t => { - await withMcpContext(async (response, context) => { - context.setViewport({width: 400, height: 400, deviceScaleFactor: 1}); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds userAgent emulation setting when it is set', async t => { - await withMcpContext(async (response, context) => { - context.setUserAgent('MyUA'); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds color scheme emulation setting when it is set', async t => { - await withMcpContext(async (response, context) => { - context.setColorScheme('dark'); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds a prompt dialog', async t => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const dialogPromise = new Promise(resolve => { - page.on('dialog', () => { - resolve(); - }); - }); - page.evaluate(() => { - prompt('message', 'default'); - }); - await dialogPromise; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - await context.getDialog()?.dismiss(); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds an alert dialog', async t => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - const dialogPromise = new Promise(resolve => { - page.on('dialog', () => { - resolve(); - }); - }); - page.evaluate(() => { - alert('message'); - }); - await dialogPromise; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - await context.getDialog()?.dismiss(); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('add network requests when setting is true', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true); - context.getNetworkRequests = () => { - return [getMockRequest({stableId: 1}), getMockRequest({stableId: 2})]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('does not include network requests when setting is false', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(false); - context.getNetworkRequests = () => { - return [getMockRequest()]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.strictEqual(getTextContent(content[0]), `# test response`); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('add network request when attached with POST data', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true); - const httpResponse = getMockResponse(); - httpResponse.buffer = () => { - return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); - }; - httpResponse.headers = () => { - return { - 'Content-Type': 'application/json', - }; - }; - const request = getMockRequest({ - method: 'POST', - hasPostData: true, - postData: JSON.stringify({request: 'body'}), - response: httpResponse, - }); - context.getNetworkRequests = () => { - return [request]; - }; - context.getNetworkRequestById = () => { - return request; - }; - response.attachNetworkRequest(1); - - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('add network request when attached', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true); - const request = getMockRequest(); - context.getNetworkRequests = () => { - return [request]; - }; - context.getNetworkRequestById = () => { - return request; - }; - response.attachNetworkRequest(1); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds console messages when the setting is true', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeConsoleData(true); - const page = context.getSelectedPage(); - const consoleMessagePromise = new Promise(resolve => { - page.on('console', () => { - resolve(); - }); - }); - page.evaluate(() => { - console.log('Hello from the test'); - }); - await consoleMessagePromise; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.ok(getTextContent(content[0])); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('adds a message when no console messages exist', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeConsoleData(true); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - assert.ok(getTextContent(content[0])); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it("doesn't list the issue message if mapping returns null", async t => { - await withMcpContext(async (response, context) => { - const mockAggregatedIssue = getMockAggregatedIssue(); - const mockDescription = { - file: 'not-existing-description-file.md', - links: [], - }; - mockAggregatedIssue.getDescription.returns(mockDescription); - response.setIncludeConsoleData(true); - context.getConsoleData = () => { - return [mockAggregatedIssue]; - }; - - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - const text = getTextContent(content[0]); - assert.ok(text.includes('')); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('throws error if mapping returns null on get issue details', async () => { - await withMcpContext(async (response, context) => { - const mockAggregatedIssue = getMockAggregatedIssue(); - const mockDescription = { - file: 'not-existing-description-file.md', - links: [], - }; - mockAggregatedIssue.getDescription.returns(mockDescription); - response.attachConsoleMessage(1); - context.getConsoleMessageById = () => { - return mockAggregatedIssue; - }; - - try { - await response.handle('test', context); - } catch (e) { - assert.ok(e.message.includes("Can't provide detals for the msgid 1")); - } - }); - }); -}); - -describe('McpResponse network request filtering', () => { - it('filters network requests by resource type', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true, { - resourceTypes: ['script', 'stylesheet'], - }); - context.getNetworkRequests = () => { - return [ - getMockRequest({resourceType: 'script'}), - getMockRequest({resourceType: 'image'}), - getMockRequest({resourceType: 'stylesheet'}), - getMockRequest({resourceType: 'document'}), - ]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('filters network requests by single resource type', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true, { - resourceTypes: ['image'], - }); - context.getNetworkRequests = () => { - return [ - getMockRequest({resourceType: 'script'}), - getMockRequest({resourceType: 'image'}), - getMockRequest({resourceType: 'stylesheet'}), - ]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('shows no requests when filter matches nothing', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true, { - resourceTypes: ['font'], - }); - context.getNetworkRequests = () => { - return [ - getMockRequest({resourceType: 'script'}), - getMockRequest({resourceType: 'image'}), - getMockRequest({resourceType: 'stylesheet'}), - ]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('shows all requests when no filters are provided', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true); - context.getNetworkRequests = () => { - return [ - getMockRequest({resourceType: 'script'}), - getMockRequest({resourceType: 'image'}), - getMockRequest({resourceType: 'stylesheet'}), - getMockRequest({resourceType: 'document'}), - getMockRequest({resourceType: 'font'}), - ]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('shows all requests when empty resourceTypes array is provided', async t => { - await withMcpContext(async (response, context) => { - response.setIncludeNetworkRequests(true, { - resourceTypes: [], - }); - context.getNetworkRequests = () => { - return [ - getMockRequest({resourceType: 'script'}), - getMockRequest({resourceType: 'image'}), - getMockRequest({resourceType: 'stylesheet'}), - getMockRequest({resourceType: 'document'}), - getMockRequest({resourceType: 'font'}), - ]; - }; - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); -}); - -describe('McpResponse network pagination', () => { - it('returns all requests when pagination is not provided', async t => { - await withMcpContext(async (response, context) => { - const requests = Array.from({length: 5}, () => getMockRequest()); - context.getNetworkRequests = () => requests; - response.setIncludeNetworkRequests(true); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - const text = getTextContent(content[0]); - assert.ok(text.includes('Showing 1-5 of 5 (Page 1 of 1).')); - assert.ok(!text.includes('Next page:')); - assert.ok(!text.includes('Previous page:')); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('returns first page by default', async t => { - await withMcpContext(async (response, context) => { - const requests = Array.from({length: 30}, (_, idx) => - getMockRequest({method: `GET-${idx}`}), - ); - context.getNetworkRequests = () => { - return requests; - }; - response.setIncludeNetworkRequests(true, {pageSize: 10}); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - const text = getTextContent(content[0]); - assert.ok(text.includes('Showing 1-10 of 30 (Page 1 of 3).')); - assert.ok(text.includes('Next page: 1')); - assert.ok(!text.includes('Previous page:')); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('returns subsequent page when pageIdx provided', async t => { - await withMcpContext(async (response, context) => { - const requests = Array.from({length: 25}, (_, idx) => - getMockRequest({method: `GET-${idx}`}), - ); - context.getNetworkRequests = () => requests; - response.setIncludeNetworkRequests(true, { - pageSize: 10, - pageIdx: 1, - }); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - const text = getTextContent(content[0]); - assert.ok(text.includes('Showing 11-20 of 25 (Page 2 of 3).')); - assert.ok(text.includes('Next page: 2')); - assert.ok(text.includes('Previous page: 0')); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - it('handles invalid page number by showing first page', async t => { - await withMcpContext(async (response, context) => { - const requests = Array.from({length: 5}, () => getMockRequest()); - context.getNetworkRequests = () => requests; - response.setIncludeNetworkRequests(true, { - pageSize: 2, - pageIdx: 10, // Invalid page number - }); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - const text = getTextContent(content[0]); - assert.ok( - text.includes('Invalid page number provided. Showing first page.'), - ); - assert.ok(text.includes('Showing 1-2 of 5 (Page 1 of 3).')); - t.assert.snapshot?.( - JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), - ); - }); - }); - - describe('trace summaries', () => { - it('includes the trace summary text and structured data', async t => { - const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); - const result = await parseRawTraceBuffer(rawData); - if (!traceResultIsSuccess(result)) { - throw new Error(result.error); - } - - await withMcpContext(async (response, context) => { - response.attachTraceSummary(result); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - - t.assert.snapshot?.(getTextContent(content[0])); - const typedStructuredContent = structuredContent as { - traceSummary?: string; - traceInsights?: unknown[]; - }; - t.assert.snapshot?.( - JSON.stringify(typedStructuredContent.traceSummary, null, 2), - ); - t.assert.snapshot?.( - JSON.stringify(typedStructuredContent.traceInsights, null, 2), - ); - }); - }); - }); - - describe('trace insights', () => { - it('includes the trace insight output', async t => { - const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); - const result = await parseRawTraceBuffer(rawData); - if (!traceResultIsSuccess(result)) { - throw new Error(result.error); - } - - await withMcpContext(async (response, context) => { - response.attachTraceInsight( - result, - 'NAVIGATION_0', - 'LCPBreakdown' as InsightName, - ); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify( - stabilizeStructuredContent(structuredContent), - null, - 2, - ), - ); - }); - }); - - it('includes error if insight not found', async t => { - const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); - const result = await parseRawTraceBuffer(rawData); - if (!traceResultIsSuccess(result)) { - throw new Error(result.error); - } - - await withMcpContext(async (response, context) => { - response.attachTraceInsight( - result, - 'BAD_ID', - 'LCPBreakdown' as InsightName, - ); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( - JSON.stringify( - stabilizeStructuredContent(structuredContent), - null, - 2, - ), - ); - }); - }); - }); -}); - -describe('extensions', () => { - it('lists extensions', async t => { - await withMcpContext(async (response, context) => { - response.setListExtensions(); - // Empty state testing - const emptyResult = await response.handle('test', context); - const emptyText = getTextContent(emptyResult.content[0]); - assert.ok( - emptyText.includes('No extensions installed.'), - 'Should show message for ampty extensions', - ); - - response.resetResponseLineForTesting(); - // Testing with extensions - context.listExtensions = () => [ - { - id: 'id1', - name: 'Extension 1', - version: '1.0', - isEnabled: true, - path: '/path/to/ext1', - }, - { - id: 'id2', - name: 'Extension 2', - version: '2.0', - isEnabled: false, - path: '/path/to/ext2', - }, - ]; - response.setListExtensions(); - const {content, structuredContent} = await response.handle( - 'test', - context, - ); - - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); - }); - }); -}); diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts deleted file mode 100644 index 48e60cf36..000000000 --- a/tests/PageCollector.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {beforeEach, describe, it} from 'node:test'; - -import type {Frame, HTTPRequest, Target, Protocol} from 'puppeteer-core'; -import sinon from 'sinon'; - -import type {ListenerMap} from '../src/PageCollector.js'; -import { - ConsoleCollector, - NetworkCollector, - PageCollector, -} from '../src/PageCollector.js'; -import {DevTools} from '../src/third_party/index.js'; - -import {getMockRequest, getMockBrowser} from './utils.js'; - -describe('PageCollector', () => { - it('works', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const request = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - page.emit('request', request); - - assert.equal(collector.getData(page)[0], request); - }); - - it('clean up after navigation', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const mainFrame = page.mainFrame(); - const request = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - page.emit('request', request); - - assert.equal(collector.getData(page)[0], request); - page.emit('framenavigated', mainFrame); - - assert.equal(collector.getData(page).length, 0); - }); - - it('does not clean up after sub frame navigation', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const request = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - page.emit('request', request); - page.emit('framenavigated', {} as Frame); - - assert.equal(collector.getData(page).length, 1); - }); - - it('clean up after navigation and be able to add data after', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const mainFrame = page.mainFrame(); - const request = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - page.emit('request', request); - - assert.equal(collector.getData(page)[0], request); - page.emit('framenavigated', mainFrame); - - assert.equal(collector.getData(page).length, 0); - - page.emit('request', request); - - assert.equal(collector.getData(page).length, 1); - }); - - it('should only subscribe once', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const request = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - browser.emit('targetcreated', { - page() { - return Promise.resolve(page); - }, - } as Target); - - // The page inside part is async so we need to await some time - await new Promise(res => res()); - - assert.equal(collector.getData(page).length, 0); - - page.emit('request', request); - - assert.equal(collector.getData(page).length, 1); - - page.emit('request', request); - - assert.equal(collector.getData(page).length, 2); - }); - - it('should clear data on page destroy', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const request = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - - page.emit('request', request); - - assert.equal(collector.getData(page).length, 1); - - browser.emit('targetdestroyed', { - page() { - return Promise.resolve(page); - }, - } as Target); - - // The page inside part is async so we need to await some time - await new Promise(res => res()); - - assert.equal(collector.getData(page).length, 0); - }); - - it('should assign ids to requests', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const request1 = getMockRequest(); - const request2 = getMockRequest(); - const collector = new PageCollector(browser, collect => { - return { - request: req => { - collect(req); - }, - } as ListenerMap; - }); - await collector.init([page]); - - page.emit('request', request1); - page.emit('request', request2); - - assert.equal(collector.getData(page).length, 2); - - assert.equal(collector.getIdForResource(request1), 1); - assert.equal(collector.getIdForResource(request2), 2); - }); -}); - -describe('NetworkCollector', () => { - it('correctly picks up navigation requests to latest navigation', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const mainFrame = page.mainFrame(); - const request = getMockRequest(); - const navRequest = getMockRequest({ - navigationRequest: true, - frame: page.mainFrame(), - }); - const request2 = getMockRequest(); - const collector = new NetworkCollector(browser); - await collector.init([page]); - page.emit('request', request); - page.emit('request', navRequest); - - assert.equal(collector.getData(page)[0], request); - assert.equal(collector.getData(page)[1], navRequest); - page.emit('framenavigated', mainFrame); - - assert.equal(collector.getData(page).length, 1); - assert.equal(collector.getData(page)[0], navRequest); - - page.emit('request', request2); - - assert.equal(collector.getData(page).length, 2); - assert.equal(collector.getData(page)[0], navRequest); - assert.equal(collector.getData(page)[1], request2); - }); - - it('correctly picks up after multiple back to back navigations', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const mainFrame = page.mainFrame(); - const navRequest = getMockRequest({ - navigationRequest: true, - frame: page.mainFrame(), - }); - const navRequest2 = getMockRequest({ - navigationRequest: true, - frame: page.mainFrame(), - }); - const request = getMockRequest(); - - const collector = new NetworkCollector(browser); - await collector.init([page]); - page.emit('request', navRequest); - assert.equal(collector.getData(page)[0], navRequest); - - page.emit('framenavigated', mainFrame); - assert.equal(collector.getData(page).length, 1); - assert.equal(collector.getData(page)[0], navRequest); - - page.emit('request', navRequest2); - assert.equal(collector.getData(page).length, 2); - assert.equal(collector.getData(page)[0], navRequest); - assert.equal(collector.getData(page)[1], navRequest2); - - page.emit('framenavigated', mainFrame); - assert.equal(collector.getData(page).length, 1); - assert.equal(collector.getData(page)[0], navRequest2); - - page.emit('request', request); - assert.equal(collector.getData(page).length, 2); - }); - - it('works with previous navigations', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - const mainFrame = page.mainFrame(); - const navRequest = getMockRequest({ - navigationRequest: true, - frame: page.mainFrame(), - }); - const navRequest2 = getMockRequest({ - navigationRequest: true, - frame: page.mainFrame(), - }); - const request = getMockRequest(); - - const collector = new NetworkCollector(browser); - await collector.init([page]); - page.emit('request', navRequest); - assert.equal(collector.getData(page, true).length, 1); - - page.emit('framenavigated', mainFrame); - assert.equal(collector.getData(page, true).length, 1); - - page.emit('request', navRequest2); - assert.equal(collector.getData(page, true).length, 2); - - page.emit('framenavigated', mainFrame); - assert.equal(collector.getData(page, true).length, 2); - - page.emit('request', request); - assert.equal(collector.getData(page, true).length, 3); - }); -}); - -describe('ConsoleCollector', () => { - let issue: Protocol.Audits.InspectorIssue; - - beforeEach(() => { - issue = { - code: 'MixedContentIssue', - details: { - mixedContentIssueDetails: { - insecureURL: 'test.url', - resolutionStatus: 'MixedContentBlocked', - mainResourceURL: '', - }, - }, - }; - }); - - it('emits issues on page', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - // @ts-expect-error internal API. - const cdpSession = page._client(); - const onIssuesListener = sinon.spy(); - - page.on('issue', onIssuesListener); - - const collector = new ConsoleCollector(browser, collect => { - return { - issue: issue => { - collect(issue as DevTools.AggregatedIssue); - }, - } as ListenerMap; - }); - await collector.init([page]); - cdpSession.emit('Audits.issueAdded', {issue}); - sinon.assert.calledOnce(onIssuesListener); - - const issueArgument = onIssuesListener.getCall(0).args[0]; - assert(issueArgument instanceof DevTools.AggregatedIssue); - }); - - it('collects issues', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - // @ts-expect-error internal API. - const cdpSession = page._client(); - - const collector = new ConsoleCollector(browser, collect => { - return { - issue: issue => { - collect(issue as DevTools.AggregatedIssue); - }, - } as ListenerMap; - }); - await collector.init([page]); - - const issue2 = { - code: 'ElementAccessibilityIssue' as const, - details: { - elementAccessibilityIssueDetails: { - nodeId: 1, - elementAccessibilityIssueReason: 'DisallowedSelectChild', - hasDisallowedAttributes: true, - }, - }, - } satisfies Protocol.Audits.InspectorIssue; - - cdpSession.emit('Audits.issueAdded', {issue}); - cdpSession.emit('Audits.issueAdded', {issue: issue2}); - const data = collector.getData(page); - assert.equal(data.length, 2); - }); - - it('filters duplicated issues', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - // @ts-expect-error internal API. - const cdpSession = page._client(); - - const collector = new ConsoleCollector(browser, collect => { - return { - issue: issue => { - collect(issue as DevTools.AggregatedIssue); - }, - } as ListenerMap; - }); - await collector.init([page]); - - cdpSession.emit('Audits.issueAdded', {issue}); - cdpSession.emit('Audits.issueAdded', {issue}); - const data = collector.getData(page); - assert.equal(data.length, 1); - const collectedIssue = data[0]; - assert(collectedIssue instanceof DevTools.AggregatedIssue); - assert.equal(collectedIssue.code(), 'MixedContentIssue'); - assert.equal(collectedIssue.getAggregatedIssuesCount(), 1); - }); - - it('emits UncaughtErrors for Runtime.exceptionThrown CDP events', async () => { - const browser = getMockBrowser(); - const page = (await browser.pages())[0]; - // @ts-expect-error internal API. - const cdpSession = page._client(); - const onUncaughtErrorListener = sinon.spy(); - const collector = new ConsoleCollector(browser, () => { - return { - uncaughtError: onUncaughtErrorListener, - } as ListenerMap; - }); - await collector.init([page]); - - cdpSession.emit('Runtime.exceptionThrown', { - exceptionDetails: { - exception: {description: 'SyntaxError: Expected {'}, - text: 'Uncaught', - stackTrace: {callFrames: []}, - }, - }); - - sinon.assert.calledOnceWithMatch( - onUncaughtErrorListener, - sinon.match(e => { - return ( - e.details.exception.description === 'SyntaxError: Expected {', - e.details.text === 'Uncaught', - e.details.stackTrace.callFrames.length === 0 - ); - }), - ); - }); -}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts deleted file mode 100644 index 4ae960581..000000000 --- a/tests/cli.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import {parseArguments} from '../src/cli.js'; - -describe('cli args parsing', () => { - const defaultArgs = { - 'category-emulation': true, - categoryEmulation: true, - 'category-performance': true, - categoryPerformance: true, - 'category-extensions': false, - categoryExtensions: false, - 'category-network': true, - categoryNetwork: true, - 'auto-connect': undefined, - autoConnect: undefined, - 'performance-crux': true, - performanceCrux: true, - 'usage-statistics': true, - usageStatistics: true, - }; - - it('parses with default args', async () => { - const args = parseArguments('1.0.0', ['node', 'main.js']); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - }); - }); - - it('parses with browser url', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--browserUrl', - 'http://localhost:3000', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - 'browser-url': 'http://localhost:3000', - browserUrl: 'http://localhost:3000', - u: 'http://localhost:3000', - }); - }); - - it('parses with user data dir', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--user-data-dir', - '/tmp/chrome-profile', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - 'user-data-dir': '/tmp/chrome-profile', - userDataDir: '/tmp/chrome-profile', - }); - }); - - it('parses an empty browser url', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--browserUrl', - '', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - 'browser-url': undefined, - browserUrl: undefined, - u: undefined, - channel: 'stable', - }); - }); - - it('parses with executable path', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--executablePath', - '/tmp/test 123/chrome', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - 'executable-path': '/tmp/test 123/chrome', - e: '/tmp/test 123/chrome', - executablePath: '/tmp/test 123/chrome', - }); - }); - - it('parses viewport', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--viewport', - '888x777', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - viewport: { - width: 888, - height: 777, - }, - }); - }); - - it('parses chrome args', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - `--chrome-arg='--no-sandbox'`, - `--chrome-arg='--disable-setuid-sandbox'`, - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - 'chrome-arg': ['--no-sandbox', '--disable-setuid-sandbox'], - chromeArg: ['--no-sandbox', '--disable-setuid-sandbox'], - }); - }); - - it('parses ignore chrome args', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - `--ignore-default-chrome-arg='--disable-extensions'`, - `--ignore-default-chrome-arg='--disable-cancel-all-touches'`, - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - 'ignore-default-chrome-arg': [ - '--disable-extensions', - '--disable-cancel-all-touches', - ], - ignoreDefaultChromeArg: [ - '--disable-extensions', - '--disable-cancel-all-touches', - ], - }); - }); - - it('parses wsEndpoint with ws:// protocol', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--wsEndpoint', - 'ws://127.0.0.1:9222/devtools/browser/abc123', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - 'ws-endpoint': 'ws://127.0.0.1:9222/devtools/browser/abc123', - wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/abc123', - w: 'ws://127.0.0.1:9222/devtools/browser/abc123', - }); - }); - - it('parses wsEndpoint with wss:// protocol', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--wsEndpoint', - 'wss://example.com:9222/devtools/browser/abc123', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - 'ws-endpoint': 'wss://example.com:9222/devtools/browser/abc123', - wsEndpoint: 'wss://example.com:9222/devtools/browser/abc123', - w: 'wss://example.com:9222/devtools/browser/abc123', - }); - }); - - it('parses wsHeaders with valid JSON', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--wsEndpoint', - 'ws://127.0.0.1:9222/devtools/browser/abc123', - '--wsHeaders', - '{"Authorization":"Bearer token","X-Custom":"value"}', - ]); - assert.deepStrictEqual(args.wsHeaders, { - Authorization: 'Bearer token', - 'X-Custom': 'value', - }); - }); - - it('parses disabled category', async () => { - const args = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--no-category-emulation', - ]); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - 'category-emulation': false, - categoryEmulation: false, - }); - }); - it('parses auto-connect', async () => { - const args = parseArguments('1.0.0', ['node', 'main.js', '--auto-connect']); - assert.deepStrictEqual(args, { - ...defaultArgs, - _: [], - headless: false, - $0: 'npx chrome-devtools-mcp@latest', - channel: 'stable', - 'auto-connect': true, - autoConnect: true, - }); - }); - - it('parses usage statistics flag', async () => { - // Test default (should be true). - const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']); - assert.strictEqual(defaultArgs.usageStatistics, true); - - // Test enabling it - const enabledArgs = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--usage-statistics', - ]); - assert.strictEqual(enabledArgs.usageStatistics, true); - - // Test disabling it - const disabledArgs = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--no-usage-statistics', - ]); - assert.strictEqual(disabledArgs.usageStatistics, false); - }); - - it('parses performance crux flag', async () => { - const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']); - assert.strictEqual(defaultArgs.performanceCrux, true); - - // force enable - const enabledArgs = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--performance-crux', - ]); - assert.strictEqual(enabledArgs.performanceCrux, true); - - const disabledArgs = parseArguments('1.0.0', [ - 'node', - 'main.js', - '--no-performance-crux', - ]); - assert.strictEqual(disabledArgs.performanceCrux, false); - }); -}); diff --git a/tests/e2e/telemetry.test.ts b/tests/e2e/telemetry.test.ts deleted file mode 100644 index 10051a676..000000000 --- a/tests/e2e/telemetry.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; -import http from 'node:http'; -import type {AddressInfo} from 'node:net'; -import path from 'node:path'; -import {describe, it} from 'node:test'; - -import type {ChromeDevToolsMcpExtension} from '../../src/telemetry/types'; - -const SERVER_PATH = path.resolve('build/src/main.js'); - -interface MockServerContext { - server: http.Server; - port: number; - events: ChromeDevToolsMcpExtension[]; - watchdogPid?: number; - waitForEvent: ( - predicate: (event: ChromeDevToolsMcpExtension) => boolean, - ) => Promise; -} - -async function startMockServer(): Promise { - const events: ChromeDevToolsMcpExtension[] = []; - let waitingResolvers: Array<{ - predicate: (event: ChromeDevToolsMcpExtension) => boolean; - resolve: (event: ChromeDevToolsMcpExtension) => void; - }> = []; - let watchdogPid: number | undefined; - - const server = http.createServer((req, res) => { - if (req.method === 'POST') { - const pidHeader = req.headers['x-watchdog-pid']; - if (pidHeader && !Array.isArray(pidHeader)) { - watchdogPid = parseInt(pidHeader, 10); - } - - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - try { - const parsed = JSON.parse(body); - // Extract internal log events - if (parsed.log_event) { - for (const logEvent of parsed.log_event) { - if (logEvent.source_extension_json) { - const ext = JSON.parse( - logEvent.source_extension_json, - ) as ChromeDevToolsMcpExtension; - events.push(ext); - - // Check if any waiters are satisfied - waitingResolvers = waitingResolvers.filter( - ({predicate, resolve}) => { - if (predicate(ext)) { - resolve(ext); - return false; - } - return true; - }, - ); - } - } - } - } catch (err) { - console.error('Failed to parse mock server request', err); - } - res.writeHead(200, {'Content-Type': 'application/json'}); - res.end(JSON.stringify({next_request_wait_millis: 100})); - }); - } else { - res.writeHead(404); - res.end(); - } - }); - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address() as AddressInfo; - return { - server, - port: address.port, - events, - get watchdogPid() { - return watchdogPid; - }, - waitForEvent: predicate => { - const existing = events.find(predicate); - if (existing) { - return Promise.resolve(existing); - } - - return new Promise(resolve => { - waitingResolvers.push({predicate, resolve}); - }); - }, - }; -} - -interface TestContext { - process?: ChildProcess; - mockServer?: MockServerContext; -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function waitForProcessExit( - pid: number, - timeoutMs = 5000, -): Promise { - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - if (!isProcessAlive(pid)) { - return; - } - await new Promise(resolve => setTimeout(resolve, 50)); - } - throw new Error(`Timeout waiting for process ${pid} to exit`); -} - -function cleanupTest(ctx: TestContext): void { - // Kill Main Process - if (ctx.process && ctx.process.exitCode === null) { - try { - ctx.process.kill('SIGKILL'); - } catch { - // ignore - } - } - // Kill Watchdog Process - if (ctx.mockServer?.watchdogPid) { - try { - process.kill(ctx.mockServer.watchdogPid, 'SIGKILL'); - } catch { - // ignore - } - } - // Stop Mock Server - if (ctx.mockServer) { - ctx.mockServer.server.close(); - } -} - -describe('Telemetry E2E', () => { - async function runTelemetryTest( - killFn: (ctx: TestContext) => void, - spawnOptions?: SpawnOptions, - ): Promise { - const mockContext = await startMockServer(); - const ctx: TestContext = { - mockServer: mockContext, - }; - - try { - ctx.process = spawn( - process.execPath, - [ - SERVER_PATH, - '--usage-statistics', - '--headless', - `--clearcutEndpoint=http://127.0.0.1:${mockContext.port}`, - '--clearcutForceFlushIntervalMs=10', - '--clearcutIncludePidHeader', - ], - { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - CI: undefined, - CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: undefined, - }, - ...spawnOptions, - }, - ); - - const startEvent = await Promise.race([ - mockContext.waitForEvent(e => e.server_start !== undefined), - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for server_start')), - 10000, - ), - ), - ]); - assert.ok(startEvent, 'server_start event not received'); - - // Now that we received an event, we should have the Watchdog PID - const watchdogPid = mockContext.watchdogPid; - assert.ok(watchdogPid, 'Watchdog PID not captured from headers'); - - // Assert Watchdog is actually running - assert.strictEqual( - isProcessAlive(watchdogPid), - true, - 'Watchdog process should be running', - ); - - // Trigger shutdown - killFn(ctx); - - // Verify shutdown event - const shutdownEvent = await Promise.race([ - mockContext.waitForEvent(e => e.server_shutdown !== undefined), - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for server_shutdown')), - 10000, - ), - ), - ]); - assert.ok(shutdownEvent, 'server_shutdown event not received'); - - // Wait for Watchdog to exit naturally - await waitForProcessExit(watchdogPid); - } finally { - cleanupTest(ctx); - } - } - - it('handles SIGKILL', () => - runTelemetryTest(ctx => { - ctx.process!.kill('SIGKILL'); - })); - - it('handles SIGTERM', () => - runTelemetryTest(ctx => { - ctx.process!.kill('SIGTERM'); - })); - - it( - 'handles POSIX process group SIGTERM', - {skip: process.platform === 'win32'}, - () => - runTelemetryTest( - ctx => { - process.kill(-ctx.process!.pid!, 'SIGTERM'); - }, - {detached: true}, - ), - ); -}); diff --git a/tests/formatters/ConsoleFormatter.test.js.snapshot b/tests/formatters/ConsoleFormatter.test.js.snapshot deleted file mode 100644 index afc9cf115..000000000 --- a/tests/formatters/ConsoleFormatter.test.js.snapshot +++ /dev/null @@ -1,104 +0,0 @@ -exports[`ConsoleFormatter > toString > formats a console.log message 1`] = ` -msgid=1 [log] Hello, world! (0 args) -`; - -exports[`ConsoleFormatter > toString > formats a console.log message with multiple arguments 1`] = ` -msgid=3 [log] Processing file: (2 args) -`; - -exports[`ConsoleFormatter > toString > formats a console.log message with one argument 1`] = ` -msgid=2 [log] Processing file: (1 args) -`; - -exports[`ConsoleFormatter > toString > formats an UncaughtError 1`] = ` -msgid=4 [error] Uncaught TypeError: Cannot read properties of undefined (0 args) -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console message with a stack trace 1`] = ` -ID: 5 -Message: log> Hello stack trace! -### Stack trace -at foo (foo.ts:10:2) -at bar (foo.ts:20:2) ---- setTimeout ------------------------- -at schedule (util.ts:5:2) -Note: line and column numbers use 1-based indexing -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console message with an Error object argument 1`] = ` -ID: 8 -Message: log> JSHandle@error -### Arguments -Arg #0: TypeError: Cannot read properties of undefined -at foo (foo.ts:10:2) -at bar (foo.ts:20:2) -Note: line and column numbers use 1-based indexing -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console message with an Error object with cause 1`] = ` -ID: 9 -Message: log> JSHandle@error -### Arguments -Arg #0: AppError: Compute failed -at foo (foo.ts:10:2) -at bar (foo.ts:20:2) -Caused by: TypeError: Cannot read properties of undefined -at compute (library.js:5:10) -Note: line and column numbers use 1-based indexing -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console.error message 1`] = ` -ID: 4 -Message: error> Something went wrong -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console.log message 1`] = ` -ID: 1 -Message: log> Hello, world! -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console.log message with multiple arguments 1`] = ` -ID: 3 -Message: log> Processing file: -### Arguments -Arg #0: file.txt -Arg #1: another file -`; - -exports[`ConsoleFormatter > toStringDetailed > formats a console.log message with one argument 1`] = ` -ID: 2 -Message: log> Processing file: -### Arguments -Arg #0: file.txt -`; - -exports[`ConsoleFormatter > toStringDetailed > formats an UncaughtError with a stack trace 1`] = ` -ID: 7 -Message: error> Uncaught TypeError: Cannot read properties of undefined -### Stack trace -at foo (foo.ts:10:2) -at bar (foo.ts:20:2) ---- setTimeout ------------------------- -at schedule (util.ts:5:2) -Note: line and column numbers use 1-based indexing -`; - -exports[`ConsoleFormatter > toStringDetailed > formats an UncaughtError with a stack trace and a cause 1`] = ` -ID: 10 -Message: error> Uncaught TypeError: Cannot read properties of undefined -### Stack trace -at foo (foo.ts:10:2) -at bar (foo.ts:20:2) ---- setTimeout ------------------------- -at schedule (util.ts:5:2) -Caused by: TypeError: Cannot read properties of undefined -at compute (library.js:5:8) -Note: line and column numbers use 1-based indexing -`; - -exports[`ConsoleFormatter > toStringDetailed > handles \"Execution context is not available\" error in args 1`] = ` -ID: 6 -Message: log> Processing file: -### Arguments -Arg #0: -`; diff --git a/tests/formatters/ConsoleFormatter.test.ts b/tests/formatters/ConsoleFormatter.test.ts deleted file mode 100644 index ade2886c1..000000000 --- a/tests/formatters/ConsoleFormatter.test.ts +++ /dev/null @@ -1,540 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import {SymbolizedError} from '../../src/DevtoolsUtils.js'; -import {ConsoleFormatter} from '../../src/formatters/ConsoleFormatter.js'; -import {UncaughtError} from '../../src/PageCollector.js'; -import type {ConsoleMessage, Protocol} from '../../src/third_party/index.js'; -import type {DevTools} from '../../src/third_party/index.js'; - -interface MockConsoleMessage { - type: () => string; - text: () => string; - args: () => Array<{ - jsonValue: () => Promise; - remoteObject: () => Protocol.Runtime.RemoteObject; - }>; - stackTrace?: DevTools.StackTrace.StackTrace.StackTrace; -} - -const createMockMessage = ( - data: Partial = {}, -): ConsoleMessage => { - return { - type: () => data.type?.() ?? 'log', - text: () => data.text?.() ?? '', - args: () => data.args?.() ?? [], - ...data, - } as unknown as ConsoleMessage; -}; - -describe('ConsoleFormatter', () => { - describe('toString', () => { - it('formats a console.log message', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Hello, world!', - }); - const result = (await ConsoleFormatter.from(message, {id: 1})).toString(); - t.assert.snapshot?.(result); - }); - - it('formats a console.log message with one argument', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => 'file.txt', - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const result = ( - await ConsoleFormatter.from(message, {id: 2, fetchDetailedData: true}) - ).toString(); - t.assert.snapshot?.(result); - }); - - it('formats a console.log message with multiple arguments', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => 'file.txt', - remoteObject: () => ({type: 'string'}), - }, - { - jsonValue: async () => 'another file', - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const result = ( - await ConsoleFormatter.from(message, {id: 3, fetchDetailedData: true}) - ).toString(); - t.assert.snapshot?.(result); - }); - - it('formats an UncaughtError', async t => { - const error = new UncaughtError( - { - exceptionId: 1, - lineNumber: 0, - columnNumber: 5, - exception: { - type: 'object', - description: 'TypeError: Cannot read properties of undefined', - }, - text: 'Uncaught', - }, - '', - ); - const result = ( - await ConsoleFormatter.from(error, {id: 4, fetchDetailedData: true}) - ).toString(); - t.assert.snapshot?.(result); - }); - }); - - describe('toStringDetailed', () => { - it('formats a console.log message', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Hello, world!', - }); - const result = ( - await ConsoleFormatter.from(message, {id: 1}) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats a console.log message with one argument', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => 'file.txt', - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const result = ( - await ConsoleFormatter.from(message, {id: 2, fetchDetailedData: true}) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats a console.log message with multiple arguments', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => 'file.txt', - remoteObject: () => ({type: 'string'}), - }, - { - jsonValue: async () => 'another file', - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const result = ( - await ConsoleFormatter.from(message, {id: 3, fetchDetailedData: true}) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats a console.error message', async t => { - const message = createMockMessage({ - type: () => 'error', - text: () => 'Something went wrong', - }); - const result = ( - await ConsoleFormatter.from(message, {id: 4}) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats a console message with a stack trace', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Hello stack trace!', - }); - const stackTrace = { - syncFragment: { - frames: [ - { - line: 10, - column: 2, - url: 'foo.ts', - name: 'foo', - }, - { - line: 20, - column: 2, - url: 'foo.ts', - name: 'bar', - }, - ], - }, - asyncFragments: [ - { - description: 'setTimeout', - frames: [ - { - line: 5, - column: 2, - url: 'util.ts', - name: 'schedule', - }, - ], - }, - ], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace; - - const result = ( - await ConsoleFormatter.from(message, { - id: 5, - resolvedStackTraceForTesting: stackTrace, - }) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('handles "Execution context is not available" error in args', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => { - throw new Error('Execution context is not available'); - }, - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const formatter = await ConsoleFormatter.from(message, { - id: 6, - fetchDetailedData: true, - }); - const result = formatter.toStringDetailed(); - t.assert.snapshot?.(result); - assert.ok(result.includes('')); - }); - - it('formats an UncaughtError with a stack trace', async t => { - const stackTrace = { - syncFragment: { - frames: [ - { - line: 10, - column: 2, - url: 'foo.ts', - name: 'foo', - }, - { - line: 20, - column: 2, - url: 'foo.ts', - name: 'bar', - }, - ], - }, - asyncFragments: [ - { - description: 'setTimeout', - frames: [ - { - line: 5, - column: 2, - url: 'util.ts', - name: 'schedule', - }, - ], - }, - ], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace; - const error = new UncaughtError( - { - exceptionId: 1, - lineNumber: 0, - columnNumber: 5, - exception: { - type: 'object', - description: 'TypeError: Cannot read properties of undefined', - }, - text: 'Uncaught', - }, - '', - ); - - const result = ( - await ConsoleFormatter.from(error, { - id: 7, - resolvedStackTraceForTesting: stackTrace, - }) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats a console message with an Error object argument', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'JSHandle@error', - }); - const stackTrace = { - syncFragment: { - frames: [ - { - line: 10, - column: 2, - url: 'foo.ts', - name: 'foo', - }, - { - line: 20, - column: 2, - url: 'foo.ts', - name: 'bar', - }, - ], - }, - asyncFragments: [], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace; - const error = SymbolizedError.createForTesting( - 'TypeError: Cannot read properties of undefined', - stackTrace, - ); - - const result = ( - await ConsoleFormatter.from(message, { - id: 8, - resolvedArgsForTesting: [error], - }) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats a console message with an Error object with cause', async t => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'JSHandle@error', - }); - const stackTrace = { - syncFragment: { - frames: [ - { - line: 10, - column: 2, - url: 'foo.ts', - name: 'foo', - }, - { - line: 20, - column: 2, - url: 'foo.ts', - name: 'bar', - }, - ], - }, - asyncFragments: [], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace; - const error = SymbolizedError.createForTesting( - 'AppError: Compute failed', - stackTrace, - SymbolizedError.createForTesting( - 'TypeError: Cannot read properties of undefined', - { - syncFragment: { - frames: [ - { - line: 5, - column: 10, - url: 'library.js', - name: 'compute', - }, - ], - }, - asyncFragments: [], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace, - ), - ); - - const result = ( - await ConsoleFormatter.from(message, { - id: 9, - resolvedArgsForTesting: [error], - }) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - - it('formats an UncaughtError with a stack trace and a cause', async t => { - const stackTrace = { - syncFragment: { - frames: [ - { - line: 10, - column: 2, - url: 'foo.ts', - name: 'foo', - }, - { - line: 20, - column: 2, - url: 'foo.ts', - name: 'bar', - }, - ], - }, - asyncFragments: [ - { - description: 'setTimeout', - frames: [ - { - line: 5, - column: 2, - url: 'util.ts', - name: 'schedule', - }, - ], - }, - ], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace; - const error = new UncaughtError( - { - exceptionId: 1, - lineNumber: 0, - columnNumber: 5, - exception: { - type: 'object', - description: 'TypeError: Cannot read properties of undefined', - }, - text: 'Uncaught', - }, - '', - ); - const cause = SymbolizedError.createForTesting( - 'TypeError: Cannot read properties of undefined', - { - syncFragment: { - frames: [ - { - line: 5, - column: 8, - url: 'library.js', - name: 'compute', - }, - ], - }, - asyncFragments: [], - } as unknown as DevTools.StackTrace.StackTrace.StackTrace, - ); - - const result = ( - await ConsoleFormatter.from(error, { - id: 10, - resolvedStackTraceForTesting: stackTrace, - resolvedCauseForTesting: cause, - }) - ).toStringDetailed(); - t.assert.snapshot?.(result); - }); - }); - describe('toJSON', () => { - it('formats a console.log message', async () => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Hello, world!', - }); - const result = (await ConsoleFormatter.from(message, {id: 1})).toJSON(); - assert.deepStrictEqual(result, { - type: 'log', - text: 'Hello, world!', - argsCount: 0, - id: 1, - }); - }); - - it('formats a console.log message with args', async () => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => 'file.txt', - remoteObject: () => ({type: 'string'}), - }, - { - jsonValue: async () => 'another file', - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const result = (await ConsoleFormatter.from(message, {id: 1})).toJSON(); - assert.deepStrictEqual(result, { - type: 'log', - text: 'Processing file:', - argsCount: 2, - id: 1, - }); - }); - }); - - describe('toJSONDetailed', () => { - it('formats a console.log message', async () => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Hello, world!', - }); - const result = ( - await ConsoleFormatter.from(message, {id: 1}) - ).toJSONDetailed(); - assert.deepStrictEqual(result, { - id: 1, - type: 'log', - text: 'Hello, world!', - args: [], - stackTrace: undefined, - }); - }); - - it('formats a console.log message with args', async () => { - const message = createMockMessage({ - type: () => 'log', - text: () => 'Processing file:', - args: () => [ - { - jsonValue: async () => 'file.txt', - remoteObject: () => ({type: 'string'}), - }, - { - jsonValue: async () => 'another file', - remoteObject: () => ({type: 'string'}), - }, - ], - }); - const result = ( - await ConsoleFormatter.from(message, {id: 2, fetchDetailedData: true}) - ).toJSONDetailed(); - assert.deepStrictEqual(result, { - id: 2, - type: 'log', - text: 'Processing file:', - args: ['file.txt', 'another file'], - stackTrace: undefined, - }); - }); - }); -}); diff --git a/tests/formatters/IssueFormatter.test.js.snapshot b/tests/formatters/IssueFormatter.test.js.snapshot deleted file mode 100644 index 10d939e22..000000000 --- a/tests/formatters/IssueFormatter.test.js.snapshot +++ /dev/null @@ -1,9 +0,0 @@ -exports[`IssueFormatter > formats an issue message 1`] = ` -ID: 5 -Message: issue> Mock Issue Title - -This is a mock issue description -Learn more: -[Learn more](http://example.com/learnmore) -[Learn more 2](http://example.com/another-learnmore) -`; diff --git a/tests/formatters/IssueFormatter.test.ts b/tests/formatters/IssueFormatter.test.ts deleted file mode 100644 index c46fbb6fa..000000000 --- a/tests/formatters/IssueFormatter.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it, beforeEach, afterEach} from 'node:test'; - -import sinon from 'sinon'; - -import {IssueFormatter} from '../../src/formatters/IssueFormatter.js'; -import {ISSUE_UTILS} from '../../src/issue-descriptions.js'; -import {getMockAggregatedIssue} from '../utils.js'; - -describe('IssueFormatter', () => { - let getIssueDescriptionStub: sinon.SinonStub; - - beforeEach(() => { - getIssueDescriptionStub = sinon.stub(ISSUE_UTILS, 'getIssueDescription'); - }); - - afterEach(() => { - getIssueDescriptionStub.restore(); - }); - - it('formats an issue message', t => { - const testGenericIssue = { - details: () => { - return { - violatingNodeId: 2, - violatingNodeAttribute: 'test', - }; - }, - }; - const mockAggregatedIssue = getMockAggregatedIssue(); - const mockDescription = { - file: 'mock.md', - links: [ - {link: 'http://example.com/learnmore', linkTitle: 'Learn more'}, - { - link: 'http://example.com/another-learnmore', - linkTitle: 'Learn more 2', - }, - ], - }; - mockAggregatedIssue.getDescription.returns(mockDescription); - // @ts-expect-error generic issue stub bypass - mockAggregatedIssue.getGenericIssues.returns(new Set([testGenericIssue])); - - const mockDescriptionFileContent = - '# Mock Issue Title\n\nThis is a mock issue description'; - - getIssueDescriptionStub - .withArgs('mock.md') - .returns(mockDescriptionFileContent); - - const formatter = new IssueFormatter(mockAggregatedIssue, { - id: 5, - }); - - const result = formatter.toStringDetailed(); - t.assert.snapshot?.(result); - }); - - describe('isValid', () => { - it('returns false for the issue with no description', () => { - const mockAggregatedIssue = getMockAggregatedIssue(); - mockAggregatedIssue.getDescription.returns(null); - - const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); - assert.strictEqual(formatter.isValid(), false); - }); - - it('returns false if there is no description file', () => { - const mockAggregatedIssue = getMockAggregatedIssue(); - mockAggregatedIssue.getDescription.returns({ - file: 'mock.md', - links: [], - }); - getIssueDescriptionStub.withArgs('mock.md').returns(null); - - const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); - assert.strictEqual(formatter.isValid(), false); - }); - - it("returns false if can't parse the title", () => { - const mockAggregatedIssue = getMockAggregatedIssue(); - mockAggregatedIssue.getDescription.returns({ - file: 'mock.md', - links: [], - }); - getIssueDescriptionStub - .withArgs('mock.md') - .returns('No title test {PLACEHOLDER_VALUE}'); - - const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); - assert.strictEqual(formatter.isValid(), false); - }); - - it('returns false if devtools util function throws an error', () => { - const mockAggregatedIssue = getMockAggregatedIssue(); - mockAggregatedIssue.getDescription.returns({ - file: 'mock.md', - links: [], - substitutions: new Map([['PLACEHOLDER_VALUE', 'substitution value']]), - }); - - getIssueDescriptionStub - .withArgs('mock.md') - .returns('No title test {WRONG_PLACEHOLDER}'); - - const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); - assert.strictEqual(formatter.isValid(), false); - }); - - it('returns true for valid issue', () => { - const mockAggregatedIssue = getMockAggregatedIssue(); - mockAggregatedIssue.getDescription.returns({ - file: 'mock.md', - links: [], - substitutions: new Map([['PLACEHOLDER_VALUE', 'substitution value']]), - }); - getIssueDescriptionStub - .withArgs('mock.md') - .returns('# Valid Title\n\nContent {PLACEHOLDER_VALUE}'); - - const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); - assert.strictEqual(formatter.isValid(), true); - - // Verify usage of substitutions in detailed output - const detailed = formatter.toStringDetailed(); - assert.ok(detailed.includes('substitution value')); - assert.ok(detailed.includes('Valid Title')); - }); - }); - describe('toJSON', () => { - it('formats a simplified issue', () => { - const mockAggregatedIssue = getMockAggregatedIssue(); - mockAggregatedIssue.getDescription.returns({ - file: 'mock.md', - links: [], - }); - mockAggregatedIssue.getAggregatedIssuesCount.returns(5); - getIssueDescriptionStub - .withArgs('mock.md') - .returns('# Issue Title\n\nIssue content'); - - const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); - assert.deepStrictEqual(formatter.toJSON(), { - type: 'issue', - title: 'Issue Title', - count: 5, - id: 1, - }); - }); - }); - - describe('toJSONDetailed', () => { - it('formats a detailed issue', () => { - const testGenericIssue = { - details: () => { - return { - violatingNodeId: 2, - violatingNodeAttribute: 'test', - }; - }, - }; - const mockAggregatedIssue = getMockAggregatedIssue(); - const mockDescription = { - file: 'mock.md', - links: [{link: 'http://example.com', linkTitle: 'Link 1'}], - substitutions: new Map([['PLACEHOLDER_VALUE', 'sub value']]), - }; - mockAggregatedIssue.getDescription.returns(mockDescription); - // @ts-expect-error stubbed generic issue does not match the complete type. - mockAggregatedIssue.getAllIssues.returns([testGenericIssue]); - - const mockDescriptionFileContent = - '# Mock Issue Title\n\nThis is a mock issue description {PLACEHOLDER_VALUE}'; - - getIssueDescriptionStub - .withArgs('mock.md') - .returns(mockDescriptionFileContent); - - const formatter = new IssueFormatter(mockAggregatedIssue, { - id: 5, - }); - - const detailedResult = formatter.toJSONDetailed() as unknown as Record< - string, - object - > & {affectedResources: Array<{data: object}>}; - assert.strictEqual(detailedResult.id, 5); - assert.strictEqual(detailedResult.type, 'issue'); - assert.strictEqual(detailedResult.title, 'Mock Issue Title'); - assert.strictEqual( - detailedResult.description, - '# Mock Issue Title\n\nThis is a mock issue description sub value', - ); - assert.deepStrictEqual(detailedResult.links, mockDescription.links); - assert.strictEqual(detailedResult.affectedResources.length, 1); - assert.deepStrictEqual(detailedResult.affectedResources[0].data, { - violatingNodeAttribute: 'test', - violatingNodeId: 2, - }); - }); - }); -}); diff --git a/tests/formatters/NetworkFormatter.test.ts b/tests/formatters/NetworkFormatter.test.ts deleted file mode 100644 index a1f1fd3c2..000000000 --- a/tests/formatters/NetworkFormatter.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; -import {afterEach, beforeEach, describe, it} from 'node:test'; - -import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js'; -import type {HTTPRequest} from '../../src/third_party/index.js'; -import {getMockRequest, getMockResponse} from '../utils.js'; - -describe('NetworkFormatter', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'network-formatter-test-')); - }); - - afterEach(async () => { - await rm(tmpDir, {recursive: true, force: true}); - }); - - describe('toString', () => { - it('works', async () => { - const request = getMockRequest(); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 GET http://example.com [pending]', - ); - }); - it('shows correct method', async () => { - const request = getMockRequest({method: 'POST'}); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 POST http://example.com [pending]', - ); - }); - it('shows correct status for request with response code in 200', async () => { - const response = getMockResponse(); - const request = getMockRequest({response}); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 GET http://example.com [success - 200]', - ); - }); - it('shows correct status for request with response code in 100', async () => { - const response = getMockResponse({ - status: 199, - }); - const request = getMockRequest({response}); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 GET http://example.com [failed - 199]', - ); - }); - it('shows correct status for request with response code above 200', async () => { - const response = getMockResponse({ - status: 300, - }); - const request = getMockRequest({response}); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 GET http://example.com [failed - 300]', - ); - }); - it('shows correct status for request that failed', async () => { - const request = getMockRequest({ - failure() { - return { - errorText: 'Error in Network', - }; - }, - }); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 GET http://example.com [failed - Error in Network]', - ); - }); - - it('marks requests selected in DevTools UI', async () => { - const request = getMockRequest(); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - selectedInDevToolsUI: true, - saveFile: async () => ({filename: ''}), - }); - - assert.equal( - formatter.toString(), - 'reqid=1 GET http://example.com [pending] [selected in the DevTools Network panel]', - ); - }); - }); - - describe('toStringDetailed', () => { - it('works with request body from fetchPostData', async () => { - const request = getMockRequest({ - hasPostData: true, - postData: undefined, - fetchPostData: Promise.resolve('test'), - }); - const formatter = await NetworkFormatter.from(request, { - requestId: 200, - fetchData: true, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toStringDetailed(); - assert.match(result, /test/); - }); - - it('works with request body from postData', async () => { - const request = getMockRequest({ - postData: JSON.stringify({ - request: 'body', - }), - hasPostData: true, - }); - const formatter = await NetworkFormatter.from(request, { - requestId: 200, - fetchData: true, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toStringDetailed(); - - assert.match( - result, - new RegExp( - JSON.stringify({ - request: 'body', - }), - ), - ); - }); - - it('truncates request body', async () => { - const request = getMockRequest({ - postData: 'some text that is longer than expected', - hasPostData: true, - }); - const formatter = await NetworkFormatter.from(request, { - requestId: 20, - fetchData: true, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toStringDetailed(); - assert.match(result, /some text/); - }); - - it('should save bodies to file when file paths are provided', async () => { - const request = { - method: () => 'POST', - url: () => 'http://example.com', - headers: () => ({}), - hasPostData: () => true, - postData: () => 'request body', - response: () => ({ - status: () => 200, - headers: () => ({}), - buffer: async () => Buffer.from('response body'), - }), - failure: () => null, - redirectChain: () => [], - fetchPostData: async () => undefined, - } as unknown as HTTPRequest; - - const reqPath = join(tmpDir, 'test_req_' + Date.now()); - const resPath = join(tmpDir, 'test_res_' + Date.now()); - - const formatter = await NetworkFormatter.from(request, { - fetchData: true, - requestFilePath: reqPath, - responseFilePath: resPath, - saveFile: async (data, filename) => { - await writeFile(filename, data); - return {filename}; - }, - }); - - const json = formatter.toJSONDetailed() as { - requestBody: string; - responseBody: string; - requestBodyFilePath: string; - responseBodyFilePath: string; - }; - assert.strictEqual(json.requestBodyFilePath, reqPath); - assert.strictEqual(json.responseBodyFilePath, resPath); - assert.strictEqual(json.requestBody, undefined); - assert.strictEqual(json.responseBody, undefined); - }); - - it('should not truncate large bodies when saving to file', async () => { - const largeBody = 'a'.repeat(10005); - const request = { - method: () => 'POST', - url: () => 'http://example.com', - headers: () => ({}), - hasPostData: () => true, - postData: () => largeBody, - response: () => ({ - status: () => 200, - headers: () => ({}), - buffer: async () => Buffer.from(largeBody), - }), - failure: () => null, - redirectChain: () => [], - fetchPostData: async () => undefined, - } as unknown as HTTPRequest; - - const reqPath = join(tmpDir, 'test_req_large_' + Date.now()); - const resPath = join(tmpDir, 'test_res_large_' + Date.now()); - - await NetworkFormatter.from(request, { - fetchData: true, - requestFilePath: reqPath, - responseFilePath: resPath, - saveFile: async (data, filename) => { - await writeFile(filename, data); - return {filename}; - }, - }); - - const reqContent = await readFile(reqPath, 'utf8'); - const resContent = await readFile(resPath, 'utf8'); - - assert.strictEqual(reqContent, largeBody); - assert.strictEqual(resContent, largeBody); - }); - - it('handles response body', async () => { - const response = getMockResponse(); - response.buffer = () => { - return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); - }; - const request = getMockRequest({response}); - - const formatter = await NetworkFormatter.from(request, { - requestId: 200, - fetchData: true, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toStringDetailed(); - - assert.match(result, /"response":"body"/); - }); - - it('handles redirect chain', async () => { - const redirectRequest = getMockRequest({ - url: 'http://example.com/redirect', - }); - const request = getMockRequest({ - redirectChain: [redirectRequest], - }); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - requestIdResolver: () => 2, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toStringDetailed(); - assert.match(result, /Redirect chain/); - assert.match(result, /reqid=2/); - }); - it('shows saved to file message in toStringDetailed', async () => { - const request = { - method: () => 'POST', - url: () => 'http://example.com', - headers: () => ({}), - hasPostData: () => true, - postData: () => 'request body', - response: () => ({ - status: () => 200, - headers: () => ({}), - buffer: async () => Buffer.from('response body'), - }), - failure: () => null, - redirectChain: () => [], - fetchPostData: async () => undefined, - } as unknown as HTTPRequest; - - const reqPath = join(tmpDir, 'req.txt'); - const resPath = join(tmpDir, 'res.txt'); - - const formatter = await NetworkFormatter.from(request, { - fetchData: true, - requestFilePath: reqPath, - responseFilePath: resPath, - saveFile: async (data, filename) => { - await writeFile(filename, data); - return {filename}; - }, - }); - - const result = formatter.toStringDetailed(); - assert.ok(result.includes(`Saved to ${reqPath}.`)); - assert.ok(result.includes(`Saved to ${resPath}.`)); - }); - - it('handles missing bodies with filepath', async () => { - const request = { - method: () => 'POST', - url: () => 'http://example.com', - headers: () => ({}), - hasPostData: () => true, // Claim we have data - postData: () => null, // But returns null - response: () => ({ - status: () => 200, - headers: () => ({}), - buffer: async () => { - throw new Error('Body not available'); - }, - }), - failure: () => null, - redirectChain: () => [], - fetchPostData: async () => { - throw new Error('Body not available'); - }, - } as unknown as HTTPRequest; - - const reqPath = join(tmpDir, 'req_missing.txt'); - const resPath = join(tmpDir, 'res_missing.txt'); - - const formatter = await NetworkFormatter.from(request, { - fetchData: true, - requestFilePath: reqPath, - responseFilePath: resPath, - saveFile: async (data, filename) => { - await writeFile(filename, data); - return {filename}; - }, - }); - - const result = formatter.toStringDetailed(); - assert.ok( - result.includes( - `### Response Body\n`, - ), - ); - }); - }); - - describe('toJSON', () => { - it('returns structured data', async () => { - const request = getMockRequest(); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - selectedInDevToolsUI: true, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toJSON(); - assert.deepEqual(result, { - requestId: 1, - method: 'GET', - url: 'http://example.com', - status: '[pending]', - selectedInDevToolsUI: true, - }); - }); - }); - - describe('toJSONDetailed', () => { - it('returns structured detailed data', async () => { - const response = getMockResponse(); - response.buffer = () => Promise.resolve(Buffer.from('response')); - const request = getMockRequest({ - response, - postData: 'request', - hasPostData: true, - }); - const formatter = await NetworkFormatter.from(request, { - requestId: 1, - fetchData: true, - saveFile: async () => ({filename: ''}), - }); - const result = formatter.toJSONDetailed(); - assert.deepEqual(result, { - requestId: 1, - method: 'GET', - url: 'http://example.com', - status: '[success - 200]', - selectedInDevToolsUI: undefined, - requestHeaders: { - 'content-size': '10', - }, - requestBody: 'request', - requestBodyFilePath: undefined, - responseHeaders: {}, - responseBody: 'response', - responseBodyFilePath: undefined, - failure: undefined, - redirectChain: undefined, - }); - }); - - it('returns file paths in structured detailed data', async () => { - const request = { - method: () => 'POST', - url: () => 'http://example.com', - headers: () => ({}), - hasPostData: () => true, - postData: () => 'request body', - response: () => ({ - status: () => 200, - headers: () => ({}), - buffer: async () => Buffer.from('response body'), - }), - failure: () => null, - redirectChain: () => [], - fetchPostData: async () => undefined, - } as unknown as HTTPRequest; - - const reqPath = join(tmpDir, 'req_json.txt'); - const resPath = join(tmpDir, 'res_json.txt'); - - const formatter = await NetworkFormatter.from(request, { - fetchData: true, - requestFilePath: reqPath, - responseFilePath: resPath, - saveFile: async (data, filename) => { - await writeFile(filename, data); - return {filename}; - }, - }); - - const result = formatter.toJSONDetailed() as { - requestBodyFilePath: string; - responseBodyFilePath: string; - requestBody?: string; - responseBody?: string; - }; - - assert.strictEqual(result.requestBodyFilePath, reqPath); - assert.strictEqual(result.responseBodyFilePath, resPath); - assert.strictEqual(result.requestBody, undefined); - assert.strictEqual(result.responseBody, undefined); - }); - }); -}); diff --git a/tests/formatters/snapshotFormatter.test.js.snapshot b/tests/formatters/snapshotFormatter.test.js.snapshot deleted file mode 100644 index bb2e1ccbf..000000000 --- a/tests/formatters/snapshotFormatter.test.js.snapshot +++ /dev/null @@ -1,20 +0,0 @@ -exports[`snapshotFormatter > does not include a note if the snapshot is already verbose 1`] = ` -Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot. -Get a verbose snapshot to include all elements if you are interested in the selected element. - -uid=1_1 checkbox "checkbox" checked - uid=1_2 statictext "text" - -`; - -exports[`snapshotFormatter > formats with DevTools data included into a snapshot 1`] = ` -uid=1_1 checkbox "checkbox" checked [selected in the DevTools Elements panel] - uid=1_2 statictext "text" - -`; - -exports[`snapshotFormatter > formats with DevTools data not included into a snapshot 1`] = ` -uid=1_1 checkbox "checkbox" checked - uid=1_2 statictext "text" - -`; diff --git a/tests/formatters/snapshotFormatter.test.ts b/tests/formatters/snapshotFormatter.test.ts deleted file mode 100644 index 281cc17ab..000000000 --- a/tests/formatters/snapshotFormatter.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import type {ElementHandle} from 'puppeteer-core'; - -import {SnapshotFormatter} from '../../src/formatters/SnapshotFormatter.js'; -import type {TextSnapshot, TextSnapshotNode} from '../../src/McpContext.js'; - -describe('snapshotFormatter', () => { - it('formats a snapshot with value properties', () => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'textbox', - name: 'textbox', - value: 'value', - children: [ - { - id: '1_2', - role: 'statictext', - name: 'text', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({root: node} as TextSnapshot); - const formatted = formatter.toString(); - assert.strictEqual( - formatted, - `uid=1_1 textbox "textbox" value="value" - uid=1_2 statictext "text" -`, - ); - }); - - it('formats a snapshot with boolean properties', () => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'button', - name: 'button', - disabled: true, - children: [ - { - id: '1_2', - role: 'statictext', - name: 'text', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({root: node} as TextSnapshot); - const formatted = formatter.toString(); - assert.strictEqual( - formatted, - `uid=1_1 button "button" disableable disabled - uid=1_2 statictext "text" -`, - ); - }); - - it('formats a snapshot with checked properties', () => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'checkbox', - name: 'checkbox', - checked: true, - children: [ - { - id: '1_2', - role: 'statictext', - name: 'text', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({root: node} as TextSnapshot); - const formatted = formatter.toString(); - assert.strictEqual( - formatted, - `uid=1_1 checkbox "checkbox" checked - uid=1_2 statictext "text" -`, - ); - }); - - it('formats a snapshot with multiple different type attributes', () => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'root', - name: 'root', - children: [ - { - id: '1_2', - role: 'button', - name: 'button', - focused: true, - disabled: true, - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - { - id: '1_3', - role: 'textbox', - name: 'textbox', - value: 'value', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({root: node} as TextSnapshot); - const formatted = formatter.toString(); - assert.strictEqual( - formatted, - `uid=1_1 root "root" - uid=1_2 button "button" disableable disabled focusable focused - uid=1_3 textbox "textbox" value="value" -`, - ); - }); - - it('formats with DevTools data not included into a snapshot', t => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'checkbox', - name: 'checkbox', - checked: true, - children: [ - { - id: '1_2', - role: 'statictext', - name: 'text', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({ - snapshotId: '1', - root: node, - idToNode: new Map(), - hasSelectedElement: true, - verbose: false, - }); - const formatted = formatter.toString(); - - t.assert.snapshot?.(formatted); - }); - - it('does not include a note if the snapshot is already verbose', t => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'checkbox', - name: 'checkbox', - checked: true, - children: [ - { - id: '1_2', - role: 'statictext', - name: 'text', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({ - snapshotId: '1', - root: node, - idToNode: new Map(), - hasSelectedElement: true, - verbose: true, - }); - const formatted = formatter.toString(); - - t.assert.snapshot?.(formatted); - }); - - it('formats with DevTools data included into a snapshot', t => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'checkbox', - name: 'checkbox', - checked: true, - children: [ - { - id: '1_2', - role: 'statictext', - name: 'text', - children: [], - elementHandle: async (): Promise | null> => { - return null; - }, - }, - ], - elementHandle: async (): Promise | null> => { - return null; - }, - }; - - const formatter = new SnapshotFormatter({ - snapshotId: '1', - root: node, - idToNode: new Map(), - hasSelectedElement: true, - selectedElementUid: '1_1', - verbose: false, - }); - const formatted = formatter.toString(); - - t.assert.snapshot?.(formatted); - }); - - it('toJSON returns expected structure', () => { - const node: TextSnapshotNode = { - id: '1_1', - role: 'root', - name: 'root', - children: [ - { - id: '1_2', - role: 'button', - name: 'button', - disabled: true, - children: [], - elementHandle: async () => null, - }, - ], - elementHandle: async () => null, - }; - - const formatter = new SnapshotFormatter({root: node} as TextSnapshot); - const json = formatter.toJSON(); - - assert.deepStrictEqual(json, { - id: '1_1', - role: 'root', - name: 'root', - children: [ - { - id: '1_2', - role: 'button', - name: 'button', - disableable: true, - disabled: true, - }, - ], - }); - }); -}); diff --git a/tests/index.test.ts b/tests/index.test.ts deleted file mode 100644 index 8806d909e..000000000 --- a/tests/index.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import fs from 'node:fs'; -import {describe, it} from 'node:test'; - -import {Client} from '@modelcontextprotocol/sdk/client/index.js'; -import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; -import {executablePath} from 'puppeteer'; - -import type {ToolDefinition} from '../src/tools/ToolDefinition'; - -describe('e2e', () => { - async function withClient( - cb: (client: Client) => Promise, - extraArgs: string[] = [], - ) { - const transport = new StdioClientTransport({ - command: 'node', - args: [ - 'build/src/index.js', - '--headless', - '--isolated', - '--executable-path', - executablePath(), - ...extraArgs, - ], - }); - const client = new Client( - { - name: 'e2e-test', - version: '1.0.0', - }, - { - capabilities: {}, - }, - ); - - try { - await client.connect(transport); - await cb(client); - } finally { - await client.close(); - } - } - it('calls a tool', async () => { - await withClient(async client => { - const result = await client.callTool({ - name: 'list_pages', - arguments: {}, - }); - assert.deepStrictEqual(result, { - content: [ - { - type: 'text', - text: '# list_pages response\n## Pages\n1: about:blank [selected]', - }, - ], - }); - }); - }); - - it('calls a tool multiple times', async () => { - await withClient(async client => { - let result = await client.callTool({ - name: 'list_pages', - arguments: {}, - }); - result = await client.callTool({ - name: 'list_pages', - arguments: {}, - }); - assert.deepStrictEqual(result, { - content: [ - { - type: 'text', - text: '# list_pages response\n## Pages\n1: about:blank [selected]', - }, - ], - }); - }); - }); - - it('has all tools', async () => { - await withClient(async client => { - const {tools} = await client.listTools(); - const exposedNames = tools.map(t => t.name).sort(); - const files = fs.readdirSync('build/src/tools'); - const definedNames = []; - for (const file of files) { - if (file === 'ToolDefinition.js') { - continue; - } - const fileTools = await import(`../src/tools/${file}`); - for (const maybeTool of Object.values(fileTools)) { - if ('name' in maybeTool) { - if (maybeTool.annotations?.conditions) { - continue; - } - definedNames.push(maybeTool.name); - } - } - } - definedNames.sort(); - assert.deepStrictEqual(exposedNames, definedNames); - }); - }); - - it('has experimental extensions tools', async () => { - await withClient( - async client => { - const {tools} = await client.listTools(); - const clickAt = tools.find(t => t.name === 'install_extension'); - assert.ok(clickAt); - }, - ['--category-extensions'], - ); - }); - - it('has experimental interop tools', async () => { - await withClient( - async client => { - const {tools} = await client.listTools(); - const getTabId = tools.find(t => t.name === 'get_tab_id'); - assert.ok(getTabId); - }, - ['--experimental-interop-tools'], - ); - }); -}); diff --git a/tests/server.ts b/tests/server.ts deleted file mode 100644 index 7278861af..000000000 --- a/tests/server.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import http, { - type IncomingMessage, - type Server, - type ServerResponse, -} from 'node:http'; -import {before, after, afterEach} from 'node:test'; - -import {html} from './utils.js'; - -export class TestServer { - #port: number; - #server: Server; - - static randomPort() { - /** - * Some ports are restricted by Chromium and will fail to connect - * to prevent we start after the - * - * https://source.chromium.org/chromium/chromium/src/+/main:net/base/port_util.cc;l=107?q=kRestrictedPorts&ss=chromium - */ - const min = 10101; - const max = 20202; - return Math.floor(Math.random() * (max - min + 1) + min); - } - - #routes: Record void> = - {}; - - constructor(port: number) { - this.#port = port; - this.#server = http.createServer((req, res) => this.#handle(req, res)); - } - - get baseUrl(): string { - return `http://localhost:${this.#port}`; - } - - getRoute(path: string) { - if (!this.#routes[path]) { - throw new Error(`Route ${path} was not setup.`); - } - return `${this.baseUrl}${path}`; - } - - addHtmlRoute(path: string, htmlContent: string) { - if (this.#routes[path]) { - throw new Error(`Route ${path} was already setup.`); - } - this.#routes[path] = (_req: IncomingMessage, res: ServerResponse) => { - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.statusCode = 200; - res.end(htmlContent); - }; - } - - addRoute( - path: string, - handler: (req: IncomingMessage, res: ServerResponse) => void, - ) { - if (this.#routes[path]) { - throw new Error(`Route ${path} was already setup.`); - } - this.#routes[path] = handler; - } - - #handle(req: IncomingMessage, res: ServerResponse) { - const url = req.url ?? ''; - const routeHandler = this.#routes[url]; - - if (routeHandler) { - routeHandler(req, res); - } else { - res.writeHead(404, {'Content-Type': 'text/html'}); - res.end( - html`

404 - Not Found

The requested page does not exist.

`, - ); - } - } - - restore() { - this.#routes = {}; - } - - start(): Promise { - return new Promise(res => { - this.#server.listen(this.#port, res); - }); - } - - stop(): Promise { - return new Promise((res, rej) => { - this.#server.close(err => { - if (err) { - rej(err); - } else { - res(); - } - }); - }); - } -} - -export function serverHooks() { - const server = new TestServer(TestServer.randomPort()); - before(async () => { - await server.start(); - }); - after(async () => { - await server.stop(); - }); - afterEach(() => { - server.restore(); - }); - - return server; -} diff --git a/tests/setup.ts b/tests/setup.ts deleted file mode 100644 index d48cc49dc..000000000 --- a/tests/setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import '../src/polyfill.js'; - -import path from 'node:path'; -import {it} from 'node:test'; - -if (!it.snapshot) { - it.snapshot = { - setResolveSnapshotPath: () => { - // Internally empty - }, - setDefaultSnapshotSerializers: () => { - // Internally empty - }, - }; -} - -// This is run by Node when we execute the tests via the --import flag. -it.snapshot.setResolveSnapshotPath(testPath => { - // By default the snapshots go into the build directory, but we want them - // in the tests/ directory. - const correctPath = testPath?.replace(path.join('build', 'tests'), 'tests'); - return correctPath + '.snapshot'; -}); - -// The default serializer is JSON.stringify which outputs a very hard to read -// snapshot. So we override it to one that shows new lines literally rather -// than via `\n`. -it.snapshot.setDefaultSnapshotSerializers([String]); diff --git a/tests/snapshot.ts b/tests/snapshot.ts deleted file mode 100644 index c10cc2f9b..000000000 --- a/tests/snapshot.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -interface ScreenshotData { - html: string; -} - -export const screenshots: Record = { - basic: { - html: '
Hello MCP
', - }, - viewportOverflow: { - html: '
View Port overflow
', - }, - button: { - html: '', - }, -}; diff --git a/tests/telemetry/clearcut-logger.test.ts b/tests/telemetry/clearcut-logger.test.ts deleted file mode 100644 index 2c4912ba4..000000000 --- a/tests/telemetry/clearcut-logger.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it, afterEach, beforeEach} from 'node:test'; - -import sinon from 'sinon'; - -import {ClearcutLogger} from '../../src/telemetry/clearcut-logger.js'; -import type {Persistence} from '../../src/telemetry/persistence.js'; -import {FilePersistence} from '../../src/telemetry/persistence.js'; -import {WatchdogMessageType} from '../../src/telemetry/types.js'; -import {WatchdogClient} from '../../src/telemetry/watchdog-client.js'; - -describe('ClearcutLogger', () => { - let mockPersistence: sinon.SinonStubbedInstance; - let mockWatchdogClient: sinon.SinonStubbedInstance; - - beforeEach(() => { - mockPersistence = sinon.createStubInstance(FilePersistence, { - loadState: Promise.resolve({ - lastActive: '', - }), - }); - mockWatchdogClient = sinon.createStubInstance(WatchdogClient); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('logToolInvocation', () => { - it('sends correct payload', async () => { - const logger = new ClearcutLogger({ - persistence: mockPersistence, - appVersion: '1.0.0', - watchdogClient: mockWatchdogClient, - }); - await logger.logToolInvocation({ - toolName: 'test_tool', - success: true, - latencyMs: 123, - }); - - assert(mockWatchdogClient.send.calledOnce); - const msg = mockWatchdogClient.send.firstCall.args[0]; - assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); - assert.strictEqual(msg.payload.tool_invocation?.tool_name, 'test_tool'); - assert.strictEqual(msg.payload.tool_invocation?.success, true); - assert.strictEqual(msg.payload.tool_invocation?.latency_ms, 123); - }); - }); - - describe('logServerStart', () => { - it('logs flag usage', async () => { - const logger = new ClearcutLogger({ - persistence: mockPersistence, - appVersion: '1.0.0', - watchdogClient: mockWatchdogClient, - }); - - await logger.logServerStart({headless: true}); - - assert(mockWatchdogClient.send.calledOnce); - const msg = mockWatchdogClient.send.firstCall.args[0]; - assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); - assert.strictEqual(msg.payload.server_start?.flag_usage?.headless, true); - }); - }); - - describe('logDailyActiveIfNeeded', () => { - it('logs daily active if needed (lastActive > 24h ago)', async () => { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - mockPersistence.loadState.resolves({ - lastActive: yesterday.toISOString(), - }); - - const logger = new ClearcutLogger({ - persistence: mockPersistence, - appVersion: '1.0.0', - watchdogClient: mockWatchdogClient, - }); - - await logger.logDailyActiveIfNeeded(); - - assert(mockWatchdogClient.send.calledOnce); - const msg = mockWatchdogClient.send.firstCall.args[0]; - assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); - assert.ok(msg.payload.daily_active); - - assert(mockPersistence.saveState.called); - }); - - it('does not log daily active if not needed (today)', async () => { - mockPersistence.loadState.resolves({ - lastActive: new Date().toISOString(), - }); - - const logger = new ClearcutLogger({ - persistence: mockPersistence, - appVersion: '1.0.0', - watchdogClient: mockWatchdogClient, - }); - - await logger.logDailyActiveIfNeeded(); - - assert(mockWatchdogClient.send.notCalled); - assert(mockPersistence.saveState.notCalled); - }); - - it('logs daily active with -1 if lastActive is missing', async () => { - mockPersistence.loadState.resolves({ - lastActive: '', - }); - - const logger = new ClearcutLogger({ - persistence: mockPersistence, - appVersion: '1.0.0', - watchdogClient: mockWatchdogClient, - }); - - await logger.logDailyActiveIfNeeded(); - - assert(mockWatchdogClient.send.calledOnce); - const msg = mockWatchdogClient.send.firstCall.args[0]; - assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); - assert.strictEqual(msg.payload.daily_active?.days_since_last_active, -1); - assert(mockPersistence.saveState.called); - }); - }); -}); diff --git a/tests/telemetry/flag-utils.test.ts b/tests/telemetry/flag-utils.test.ts deleted file mode 100644 index f7105ce14..000000000 --- a/tests/telemetry/flag-utils.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert/strict'; -import {describe, it} from 'node:test'; - -import type {cliOptions} from '../../src/cli.js'; -import {computeFlagUsage} from '../../src/telemetry/flag-utils.js'; - -describe('computeFlagUsage', () => { - const mockOptions = { - boolFlag: { - type: 'boolean' as const, - description: 'A boolean flag', - }, - stringFlag: { - type: 'string' as const, - description: 'A string flag', - }, - enumFlag: { - type: 'string' as const, - description: 'An enum flag', - choices: ['a', 'b'], - }, - flagWithDefault: { - type: 'boolean' as const, - description: 'A flag with a default value', - default: false, - }, - } as unknown as typeof cliOptions; - - it('logs boolean flags directly with snake_case keys', () => { - const args = {boolFlag: true}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.bool_flag, true); - }); - - it('logs boolean flags as false when false', () => { - const args = {boolFlag: false}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.bool_flag, false); - }); - - it('logs enum flags as uppercase strings prefixed by snake case flag name', () => { - const args = {enumFlag: 'a'}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.enum_flag, 'ENUM_FLAG_A'); - }); - - it('logs other flags as present with snake_case keys', () => { - const args = {stringFlag: 'value'}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.string_flag, undefined); - assert.equal(usage.string_flag_present, true); - }); - - it('handles undefined/null values', () => { - const args = {stringFlag: undefined}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.string_flag_present, false); - }); - - describe('defaults behavior', () => { - it('logs presence when default exists and user provides different value', () => { - // Case 1: Default exists, and a value is provided by the user. - // default is false, user provides true. - const args = {flagWithDefault: true}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.flag_with_default, true); - assert.equal(usage.flag_with_default_present, true); - }); - - it('does not log presence when default exists and user provides no value', () => { - // Case 2a: Default exists, and a value is not provided by the user. - // Argument parsing would populate with default. - const args = {flagWithDefault: false}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.flag_with_default, false); - assert.equal(usage.flag_with_default_present, undefined); - }); - - it('does not log presence when default exists and user explicitly provides the default value', () => { - // Case 2b: User explicitly provides 'false', which matches default. - const args = {flagWithDefault: false}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.flag_with_default, false); - assert.equal(usage.flag_with_default_present, undefined); - }); - - it('logs presence when no default exists and user provides value', () => { - // Case 3: No default, user provides value. - const args = {stringFlag: 'value'}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.string_flag_present, true); - }); - - it('logs non-presence when no default exists and user provides no value', () => { - // Case 4: No default, user provides nothing. - const args = {}; - const usage = computeFlagUsage(args, mockOptions); - assert.equal(usage.string_flag_present, false); - }); - }); -}); diff --git a/tests/telemetry/metric-utils.test.ts b/tests/telemetry/metric-utils.test.ts deleted file mode 100644 index 789594f3c..000000000 --- a/tests/telemetry/metric-utils.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import {bucketizeLatency} from '../../src/telemetry/metric-utils.js'; - -describe('bucketizeLatency', () => { - it('should bucketize values correctly', () => { - assert.strictEqual(bucketizeLatency(0), 50); - assert.strictEqual(bucketizeLatency(25), 50); - assert.strictEqual(bucketizeLatency(50), 50); - - assert.strictEqual(bucketizeLatency(51), 100); - assert.strictEqual(bucketizeLatency(100), 100); - - assert.strictEqual(bucketizeLatency(101), 250); - assert.strictEqual(bucketizeLatency(250), 250); - - assert.strictEqual(bucketizeLatency(499), 500); - assert.strictEqual(bucketizeLatency(500), 500); - - assert.strictEqual(bucketizeLatency(900), 1000); - assert.strictEqual(bucketizeLatency(1000), 1000); - - assert.strictEqual(bucketizeLatency(2000), 2500); - assert.strictEqual(bucketizeLatency(2500), 2500); - - assert.strictEqual(bucketizeLatency(4000), 5000); - assert.strictEqual(bucketizeLatency(5000), 5000); - - assert.strictEqual(bucketizeLatency(6000), 10000); - assert.strictEqual(bucketizeLatency(10000), 10000); - - assert.strictEqual(bucketizeLatency(10001), 10000); - assert.strictEqual(bucketizeLatency(99999), 10000); - }); -}); diff --git a/tests/telemetry/persistence.test.ts b/tests/telemetry/persistence.test.ts deleted file mode 100644 index 9bacada9a..000000000 --- a/tests/telemetry/persistence.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import {describe, it, afterEach, beforeEach} from 'node:test'; - -import * as persistence from '../../src/telemetry/persistence.js'; - -describe('FilePersistence', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = path.join( - await fs.realpath(os.tmpdir()), - `telemetry-test-${crypto.randomUUID()}`, - ); - await fs.mkdir(tmpDir, {recursive: true}); - }); - - afterEach(async () => { - await fs.rm(tmpDir, {recursive: true, force: true}); - }); - - describe('loadState', () => { - it('returns default state if file does not exist', async () => { - const filePersistence = new persistence.FilePersistence(tmpDir); - const state = await filePersistence.loadState(); - assert.deepStrictEqual(state, { - lastActive: '', - }); - }); - - it('returns stored state if file exists', async () => { - const expectedState = { - lastActive: '2023-01-01T00:00:00.000Z', - }; - await fs.writeFile( - path.join(tmpDir, 'telemetry_state.json'), - JSON.stringify(expectedState), - ); - - const filePersistence = new persistence.FilePersistence(tmpDir); - const state = await filePersistence.loadState(); - assert.deepStrictEqual(state, expectedState); - }); - }); - - describe('saveState', () => { - it('saves state to file', async () => { - const state = { - lastActive: '2023-01-01T00:00:00.000Z', - }; - const filePersistence = new persistence.FilePersistence(tmpDir); - await filePersistence.saveState(state); - - const content = await fs.readFile( - path.join(tmpDir, 'telemetry_state.json'), - 'utf-8', - ); - assert.deepStrictEqual(JSON.parse(content), state); - }); - }); -}); diff --git a/tests/telemetry/watchdog-client.test.ts b/tests/telemetry/watchdog-client.test.ts deleted file mode 100644 index 258d3cac6..000000000 --- a/tests/telemetry/watchdog-client.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {ChildProcess} from 'node:child_process'; -import {Writable} from 'node:stream'; -import {describe, it, afterEach, beforeEach} from 'node:test'; - -import sinon from 'sinon'; - -import {OsType, WatchdogMessageType} from '../../src/telemetry/types.js'; -import {WatchdogClient} from '../../src/telemetry/watchdog-client.js'; - -describe('WatchdogClient', () => { - let spawnStub: sinon.SinonStub; - let stdinStub: sinon.SinonStubbedInstance; - let mockChildProcess: sinon.SinonStubbedInstance; - - beforeEach(() => { - stdinStub = sinon.createStubInstance(Writable); - mockChildProcess = sinon.createStubInstance(ChildProcess); - spawnStub = sinon.stub().returns(mockChildProcess); - - Object.defineProperty(mockChildProcess, 'stdin', { - value: stdinStub, - writable: true, - }); - Object.defineProperty(mockChildProcess, 'pid', { - value: 12345, - writable: true, - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('spawns watchdog process with correct arguments', () => { - new WatchdogClient( - { - parentPid: 100, - appVersion: '1.2.3', - osType: OsType.OS_TYPE_MACOS, - }, - {spawn: spawnStub}, - ); - - assert.ok(spawnStub.calledOnce, 'Expected `spawn` to be called'); - const args = spawnStub.firstCall.args; - const cmdArgs = args[1]; - - assert.match( - cmdArgs[0], - /watchdog[/\\]main\.js$/, - 'First argument should be path to watchdog/main.js', - ); - assert.ok( - cmdArgs.includes('--parent-pid=100'), - 'Arguments should include parent PID', - ); - assert.ok( - cmdArgs.includes('--app-version=1.2.3'), - 'Arguments should include app version', - ); - assert.ok( - cmdArgs.includes('--os-type=2'), - 'Arguments should include OS type', - ); - assert.strictEqual( - spawnStub.firstCall.args[2].detached, - true, - 'Process should be spawned as detached', - ); - }); - - it('passes log-file argument if provided', () => { - new WatchdogClient( - { - parentPid: 100, - appVersion: '1.0.0', - osType: OsType.OS_TYPE_LINUX, - logFile: '/tmp/test.log', - }, - {spawn: spawnStub}, - ); - - const cmdArgs = spawnStub.firstCall.args[1]; - assert.ok( - cmdArgs.includes('--log-file=/tmp/test.log'), - 'Arguments should include log file path', - ); - }); - - it('sends IPC messages via stdin', () => { - const client = new WatchdogClient( - { - parentPid: 100, - appVersion: '1.0.0', - osType: OsType.OS_TYPE_LINUX, - }, - {spawn: spawnStub}, - ); - - const msg = {type: WatchdogMessageType.LOG_EVENT, payload: {}}; - client.send(msg); - - assert.ok( - stdinStub.write.calledOnce, - 'Expected `stdin.write` to be called', - ); - - const writtenData = stdinStub.write.firstCall.args[0]; - assert.strictEqual( - writtenData.trim(), - JSON.stringify(msg), - 'Written data should match expected JSON message', - ); - }); - - it('handles write errors gracefully', () => { - const client = new WatchdogClient( - { - parentPid: 100, - appVersion: '1.0.0', - osType: OsType.OS_TYPE_LINUX, - }, - {spawn: spawnStub}, - ); - - stdinStub.write.throws(new Error('EPIPE')); - - assert.doesNotThrow(() => { - client.send({type: WatchdogMessageType.LOG_EVENT, payload: {}}); - }, 'Client should catch and ignore write errors'); - }); -}); diff --git a/tests/telemetry/watchdog/clearcut-sender.test.ts b/tests/telemetry/watchdog/clearcut-sender.test.ts deleted file mode 100644 index 8eca6a7f9..000000000 --- a/tests/telemetry/watchdog/clearcut-sender.test.ts +++ /dev/null @@ -1,451 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import crypto from 'node:crypto'; -import {describe, it, afterEach, beforeEach} from 'node:test'; - -import sinon from 'sinon'; - -import {OsType} from '../../../src/telemetry/types.js'; -import type {LogRequest} from '../../../src/telemetry/types.js'; -import {ClearcutSender} from '../../../src/telemetry/watchdog/clearcut-sender.js'; - -const FLUSH_INTERVAL_MS = 15 * 1000; - -describe('ClearcutSender', () => { - let randomUUIDStub: sinon.SinonStub; - let fetchStub: sinon.SinonStub; - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = sinon.useFakeTimers({ - now: Date.now(), - toFake: ['setTimeout', 'clearTimeout', 'Date'], - }); - - let uuidCounter = 0; - randomUUIDStub = sinon.stub(crypto, 'randomUUID').callsFake(() => { - return `uuid-${++uuidCounter}` as ReturnType; - }); - fetchStub = sinon.stub(global, 'fetch'); - fetchStub.resolves(new Response(JSON.stringify({}), {status: 200})); - }); - - afterEach(() => { - randomUUIDStub.restore(); - fetchStub.restore(); - clock.restore(); - sinon.restore(); - }); - - it('enriches events with app version, os type, and session id', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({mcp_client: undefined}); - assert.strictEqual(sender.bufferSizeForTesting, 1); - - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - - assert.strictEqual(fetchStub.callCount, 1); - const requestBody = JSON.parse( - fetchStub.firstCall.args[1].body, - ) as LogRequest; - const event = JSON.parse(requestBody.log_event[0].source_extension_json); - - assert.strictEqual(event.session_id, 'uuid-1'); - assert.strictEqual(event.app_version, '1.0.0'); - assert.strictEqual(event.os_type, OsType.OS_TYPE_MACOS); - }); - - it('accumulates events in buffer without immediate send', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({ - tool_invocation: {tool_name: 'test1', success: true, latency_ms: 100}, - }); - sender.enqueueEvent({ - tool_invocation: {tool_name: 'test2', success: true, latency_ms: 200}, - }); - - assert.strictEqual(sender.bufferSizeForTesting, 2); - assert.strictEqual(fetchStub.callCount, 0); - - sender.stopForTesting(); - }); - - it('sends correct LogRequest format', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({ - tool_invocation: {tool_name: 'test', success: true, latency_ms: 100}, - }); - - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - - const [url, options] = fetchStub.firstCall.args; - assert.strictEqual( - url, - 'https://play.googleapis.com/log?format=json_proto', - ); - assert.strictEqual(options.method, 'POST'); - assert.strictEqual(options.headers['Content-Type'], 'application/json'); - - const body = JSON.parse(options.body) as LogRequest; - assert.strictEqual(body.log_source, 2839); - assert.strictEqual(body.client_info.client_type, 47); - assert.ok(body.request_time_ms); - }); - - it('clears buffer on successful send', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({}); - sender.enqueueEvent({}); - assert.strictEqual(sender.bufferSizeForTesting, 2); - - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - assert.strictEqual(sender.bufferSizeForTesting, 0); - }); - - it('keeps events in buffer on transient 5xx error', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - fetchStub.resolves(new Response('Server Error', {status: 500})); - - sender.enqueueEvent({}); - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - - assert.strictEqual(sender.bufferSizeForTesting, 1); - }); - - it('keeps events in buffer on transient 429 error', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - fetchStub.resolves(new Response('Too Many Requests', {status: 429})); - - sender.enqueueEvent({}); - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - - assert.strictEqual(sender.bufferSizeForTesting, 1); - }); - - it('drops batch on permanent 4xx error', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - fetchStub.resolves(new Response('Bad Request', {status: 400})); - - sender.enqueueEvent({}); - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - - assert.strictEqual(sender.bufferSizeForTesting, 0); - }); - - it('keeps events in buffer on network error', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - fetchStub.rejects(new Error('Network error')); - - sender.enqueueEvent({}); - await clock.tickAsync(FLUSH_INTERVAL_MS); - sender.stopForTesting(); - - assert.strictEqual(sender.bufferSizeForTesting, 1); - }); - - it('sendShutdownEvent sends an immediate server_shutdown event', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - await sender.sendShutdownEvent(); - - assert.strictEqual(fetchStub.callCount, 1); - const requestBody = JSON.parse( - fetchStub.firstCall.args[1].body, - ) as LogRequest; - const event = JSON.parse(requestBody.log_event[0].source_extension_json); - - assert.ok(event.server_shutdown); - }); - - it('shutdown includes buffered events', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({ - tool_invocation: {tool_name: 'test', success: true, latency_ms: 100}, - }); - await sender.sendShutdownEvent(); - - const requestBody = JSON.parse( - fetchStub.firstCall.args[1].body, - ) as LogRequest; - assert.strictEqual(requestBody.log_event.length, 2); - }); - - it('correctly handles buffer overflow during queued flush', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({ - tool_invocation: {tool_name: 'initial', success: true, latency_ms: 100}, - }); - let resolveRequest: (value: Response) => void; - - fetchStub.onFirstCall().returns( - new Promise(resolve => { - resolveRequest = resolve; - }), - ); - - clock.tick(FLUSH_INTERVAL_MS); - - for (let i = 0; i < 1100; i++) { - sender.enqueueEvent({ - tool_invocation: { - tool_name: `overflow-${i}`, - success: true, - latency_ms: 100, - }, - }); - } - - assert.strictEqual(sender.bufferSizeForTesting, 1000); - - resolveRequest!(new Response(JSON.stringify({}), {status: 200})); - - assert.strictEqual(sender.bufferSizeForTesting, 1000); - - sender.stopForTesting(); - }); - - it('does not duplicate events when shutdown occurs during an active flush', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - sender.enqueueEvent({ - tool_invocation: { - tool_name: 'test-event', - success: true, - latency_ms: 100, - }, - }); - - let resolveFirstRequest: (value: Response) => void; - fetchStub.onFirstCall().returns( - new Promise(resolve => { - resolveFirstRequest = resolve; - }), - ); - - clock.tick(FLUSH_INTERVAL_MS); - - const shutdownPromise = sender.sendShutdownEvent(); - - resolveFirstRequest!(new Response(JSON.stringify({}), {status: 200})); - await shutdownPromise; - - assert.strictEqual(fetchStub.callCount, 2); - const firstBody = JSON.parse(fetchStub.args[0][1].body) as LogRequest; - const secondBody = JSON.parse(fetchStub.args[1][1].body) as LogRequest; - - const firstEvents = firstBody.log_event.map(e => - JSON.parse(e.source_extension_json), - ); - const secondEvents = secondBody.log_event.map(e => - JSON.parse(e.source_extension_json), - ); - - assert.strictEqual(firstEvents.length, 1); - assert.strictEqual(firstEvents[0].tool_invocation?.tool_name, 'test-event'); - - assert.strictEqual( - secondEvents.length, - 1, - 'Shutdown request should only contain shutdown event', - ); - assert.ok( - secondEvents[0].server_shutdown, - 'Shutdown request should contain server_shutdown', - ); - - sender.stopForTesting(); - }); - - it('rotates session id after 24 hours', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - sender.enqueueEvent({ - tool_invocation: {tool_name: 'test1', success: true, latency_ms: 10}, - }); - await clock.tickAsync(FLUSH_INTERVAL_MS); - - const firstCallBody = JSON.parse( - fetchStub.firstCall.args[1].body, - ) as LogRequest; - const firstEvent = JSON.parse( - firstCallBody.log_event[0].source_extension_json, - ); - const firstSessionId = firstEvent.session_id; - - const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; - await clock.tickAsync( - SESSION_ROTATION_INTERVAL_MS - FLUSH_INTERVAL_MS + 1000, - ); - - sender.enqueueEvent({ - tool_invocation: {tool_name: 'test2', success: true, latency_ms: 10}, - }); - await clock.tickAsync(FLUSH_INTERVAL_MS); - - const secondCallBody = JSON.parse( - fetchStub.secondCall.args[1].body, - ) as LogRequest; - const secondEvent = JSON.parse( - secondCallBody.log_event[0].source_extension_json, - ); - const secondSessionId = secondEvent.session_id; - - assert.notStrictEqual(firstSessionId, secondSessionId); - - sender.stopForTesting(); - }); - - it('respects next_request_wait_millis from server', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - - fetchStub.resolves( - new Response( - JSON.stringify({ - next_request_wait_millis: 45000, - }), - {status: 200}, - ), - ); - - sender.enqueueEvent({}); - await clock.tickAsync(FLUSH_INTERVAL_MS); - - fetchStub.resetHistory(); - - sender.enqueueEvent({}); - - await clock.tickAsync(44000); - assert.strictEqual( - fetchStub.callCount, - 0, - 'Should not flush before wait time', - ); - - await clock.tickAsync(1000); - assert.strictEqual(fetchStub.callCount, 1, 'Should flush after wait time'); - - sender.stopForTesting(); - }); - - it('aborts request after timeout', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - const REQUEST_TIMEOUT_MS = 30000; - - let fetchSignal: AbortSignal | undefined; - fetchStub.callsFake((_url, options) => { - fetchSignal = options.signal; - return new Promise(() => { - // Hangs forever - }); - }); - - sender.enqueueEvent({}); - - await clock.tickAsync(FLUSH_INTERVAL_MS); - await clock.tickAsync(REQUEST_TIMEOUT_MS); - - assert.ok(fetchSignal, 'Fetch should have been called with a signal'); - assert.strictEqual( - fetchSignal.aborted, - true, - 'Signal should be aborted after timeout', - ); - - sender.stopForTesting(); - }); - - it('resolves sendShutdownEvent after timeout if flush hangs', async () => { - const sender = new ClearcutSender({ - appVersion: '1.0.0', - osType: OsType.OS_TYPE_MACOS, - forceFlushIntervalMs: FLUSH_INTERVAL_MS, - }); - fetchStub.returns( - new Promise(() => { - // Hangs forever - }), - ); - - const shutdownPromise = sender.sendShutdownEvent(); - - await clock.tickAsync(5000); - - await shutdownPromise; - }); -}); diff --git a/tests/third_party_notices.test.js.snapshot b/tests/third_party_notices.test.js.snapshot deleted file mode 100644 index 33f239d09..000000000 --- a/tests/third_party_notices.test.js.snapshot +++ /dev/null @@ -1,2013 +0,0 @@ -exports[`THIRD_PARTY_NOTICES > matches snapshot if exists 1`] = ` -Name: core-js -URL: https://core-js.io -Version: -License: MIT - -Copyright (c) 2013–2025 Denis Pushkarev (zloirock.ru) -Copyright (c) 2025–2026 CoreJS Company (core-js.io) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: cliui -URL: yargs/cliui -Version: -License: ISC - -Copyright (c) 2015, Contributors - -Permission to use, copy, modify, and/or distribute this software -for any purpose with or without fee is hereby granted, provided -that the above copyright notice and this permission notice -appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES -OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE -LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES -OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, -WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, -ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ansi-regex -URL: chalk/ansi-regex -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: strip-ansi -URL: chalk/strip-ansi -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: get-east-asian-width -URL: sindresorhus/get-east-asian-width -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: emoji-regex -URL: https://mths.be/emoji-regex -Version: -License: MIT - -Copyright Mathias Bynens - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: string-width -URL: sindresorhus/string-width -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ansi-styles -URL: chalk/ansi-styles -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: wrap-ansi -URL: chalk/wrap-ansi -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: escalade -URL: lukeed/escalade -Version: -License: MIT - -MIT License - -Copyright (c) Luke Edwards (lukeed.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: yargs-parser -URL: https://github.com/yargs/yargs-parser.git -Version: -License: ISC - -Copyright (c) 2016, Contributors - -Permission to use, copy, modify, and/or distribute this software -for any purpose with or without fee is hereby granted, provided -that the above copyright notice and this permission notice -appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES -OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE -LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES -OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, -WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, -ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: yargs -URL: https://yargs.js.org/ -Version: -License: MIT - -MIT License - -Copyright 2010 James Halliday (mail@substack.net); Modified work Copyright 2014 Contributors (ben@npmjs.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: y18n -URL: https://github.com/yargs/y18n -Version: -License: ISC - -Copyright (c) 2015, Contributors - -Permission to use, copy, modify, and/or distribute this software for any purpose -with or without fee is hereby granted, provided that the above copyright notice -and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF -THIS SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: get-caller-file -URL: https://github.com/stefanpenner/get-caller-file#readme -Version: -License: ISC - -ISC License (ISC) -Copyright 2018 Stefan Penner - -Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: debug -URL: git://github.com/debug-js/debug.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2014-2017 TJ Holowaychuk -Copyright (c) 2018-2021 Josh Junon - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software -and associated documentation files (the 'Software'), to deal in the Software without restriction, -including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ms -URL: vercel/ms -Version: -License: MIT - -The MIT License (MIT) - -Copyright (c) 2020 Vercel, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: has-flag -URL: sindresorhus/has-flag -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: supports-color -URL: chalk/supports-color -Version: -License: MIT - -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: zod -URL: https://zod.dev -Version: -License: MIT - -MIT License - -Copyright (c) 2025 Colin McDonnell - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: @modelcontextprotocol/sdk -URL: https://modelcontextprotocol.io -Version: -License: MIT - -MIT License - -Copyright (c) 2024 Anthropic, PBC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: zod-to-json-schema -URL: https://github.com/StefanTerdell/zod-to-json-schema -Version: -License: ISC - -ISC License - -Copyright (c) 2020, Stefan Terdell - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ajv -URL: https://ajv.js.org -Version: -License: MIT - -The MIT License (MIT) - -Copyright (c) 2015-2021 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: fast-deep-equal -URL: https://github.com/epoberezkin/fast-deep-equal#readme -Version: -License: MIT - -MIT License - -Copyright (c) 2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: json-schema-traverse -URL: https://github.com/epoberezkin/json-schema-traverse#readme -Version: -License: MIT - -MIT License - -Copyright (c) 2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: fast-uri -URL: https://github.com/fastify/fast-uri -Version: -License: BSD-3-Clause - -Copyright (c) 2011-2021, Gary Court until https://github.com/garycourt/uri-js/commit/a1acf730b4bba3f1097c9f52e7d9d3aba8cdcaae -Copyright (c) 2021-present The Fastify team -All rights reserved. - -The Fastify team members are listed at https://github.com/fastify/fastify#team. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * The names of any contributors may not be used to endorse or promote - products derived from this software without specific prior written - permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - * * * - -The complete list of contributors can be found at: -- https://github.com/garycourt/uri-js/graphs/contributors - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ajv-formats -URL: https://github.com/ajv-validator/ajv-formats#readme -Version: -License: MIT - -MIT License - -Copyright (c) 2020 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: puppeteer-core -URL: https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer-core -Version: -License: Apache-2.0 - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: @puppeteer/browsers -URL: https://github.com/puppeteer/puppeteer/tree/main/packages/browsers -Version: -License: Apache-2.0 - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: semver -URL: git+https://github.com/npm/node-semver.git -Version: -License: ISC - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: proxy-agent -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2013 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: lru-cache -URL: git://github.com/isaacs/node-lru-cache.git -Version: -License: ISC - -The ISC License - -Copyright (c) 2010-2023 Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: agent-base -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2013 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: proxy-from-env -URL: https://github.com/Rob--W/proxy-from-env#readme -Version: -License: MIT - -The MIT License - -Copyright (C) 2016-2018 Rob Wu - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: http-proxy-agent -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2013 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: https-proxy-agent -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2013 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: socks-proxy-agent -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2013 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: socks -URL: https://github.com/JoshGlazebrook/socks/ -Version: -License: MIT - -The MIT License (MIT) - -Copyright (c) 2013 Josh Glazebrook - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: smart-buffer -URL: https://github.com/JoshGlazebrook/smart-buffer/ -Version: -License: MIT - -The MIT License (MIT) - -Copyright (c) 2013-2017 Josh Glazebrook - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ip-address -URL: git://github.com/beaugunderson/ip-address.git -Version: -License: MIT - -Copyright (C) 2011 by Beau Gunderson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: pac-proxy-agent -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2014 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: get-uri -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2014 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: data-uri-to-buffer -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2014 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: basic-ftp -URL: https://github.com/patrickjuchli/basic-ftp.git -Version: -License: MIT - -Copyright (c) 2019 Patrick Juchli - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: pac-resolver -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2013 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: degenerator -URL: https://github.com/TooTallNate/proxy-agents.git -Version: -License: MIT - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: escodegen -URL: http://github.com/estools/escodegen -Version: -License: BSD-2-Clause - -Copyright (C) 2012 Yusuke Suzuki (twitter: @Constellation) and other contributors. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: estraverse -URL: https://github.com/estools/estraverse -Version: -License: BSD-2-Clause - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: esutils -URL: https://github.com/estools/esutils -Version: -License: BSD-2-Clause - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: source-map -URL: https://github.com/mozilla/source-map -Version: -License: BSD-3-Clause - - -Copyright (c) 2009-2011, Mozilla Foundation and contributors -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the names of the Mozilla Foundation nor the names of project - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: esprima -URL: http://esprima.org -Version: -License: BSD-2-Clause - -Copyright JS Foundation and other contributors, https://js.foundation/ - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ast-types -URL: http://github.com/benjamn/ast-types -Version: -License: MIT - -Copyright (c) 2013 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: tslib -URL: https://www.typescriptlang.org/ -Version: -License: 0BSD - -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: netmask -URL: https://github.com/rs/node-netmask -Version: -License: MIT - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: @tootallnate/quickjs-emscripten -URL: https://github.com/justjake/quickjs-emscripten -Version: -License: MIT - -MIT License - -quickjs-emscripten copyright (c) 2019 Jake Teton-Landis - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: progress -URL: git://github.com/visionmedia/node-progress -Version: -License: MIT - -(The MIT License) - -Copyright (c) 2017 TJ Holowaychuk - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: ws -URL: https://github.com/websockets/ws -Version: -License: MIT - -Copyright (c) 2011 Einar Otto Stangvik -Copyright (c) 2013 Arnout Kazemier and contributors -Copyright (c) 2016 Luigi Pinca and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: chrome-devtools-frontend -License: Apache-2.0 - -// Copyright 2014 The Chromium Authors -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are -// met: -// -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above -// copyright notice, this list of conditions and the following disclaimer -// in the documentation and/or other materials provided with the -// distribution. -// * Neither the name of Google Inc. nor the names of its -// contributors may be used to endorse or promote products derived from -// this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: i18n -License: - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2014 Google Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: intl-messageformat -License: - -Copyright (c) 2019, Oath Inc. - -Licensed under the terms of the New BSD license. See below for terms. - -Redistribution and use of this software in source and binary forms, -with or without modification, are permitted provided that the following -conditions are met: - -- Redistributions of source code must retain the above - copyright notice, this list of conditions and the - following disclaimer. - -- Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the - following disclaimer in the documentation and/or other - materials provided with the distribution. - -- Neither the name of Oath Inc. nor the names of its - contributors may be used to endorse or promote products - derived from this software without specific prior - written permission of Oath Inc. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: legacy-javascript -License: - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: marked -License: - -## Marked - -Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -## Markdown - -Copyright © 2004, John Gruber -http://daringfireball.net/ -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - -This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, -the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, -indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; -or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way -out of the use of this software, even if advised of the possibility of such damage. - - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: source-map-scopes-codec -License: - -Copyright 2025 The Chromium Authors - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------------- DEPENDENCY DIVIDER -------------------- - -Name: third-party-web -License: - -The MIT License (MIT) -Copyright (c) 2019 Patrick Hulce - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -`; diff --git a/tests/third_party_notices.test.ts b/tests/third_party_notices.test.ts deleted file mode 100644 index f88ce1879..000000000 --- a/tests/third_party_notices.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import {describe, it} from 'node:test'; - -describe('THIRD_PARTY_NOTICES', () => { - it('matches snapshot if exists', t => { - const noticesPath = path.join( - process.cwd(), - 'build/src/third_party/THIRD_PARTY_NOTICES', - ); - if (fs.existsSync(noticesPath)) { - const content = fs.readFileSync(noticesPath, 'utf-8'); - const normalizedContent = content.replace( - /^Version: .*$/gm, - 'Version: ', - ); - t.assert.snapshot?.(normalizedContent); - } - }); -}); diff --git a/tests/tools/console.test.js.snapshot b/tests/tools/console.test.js.snapshot deleted file mode 100644 index dc7ed1c55..000000000 --- a/tests/tools/console.test.js.snapshot +++ /dev/null @@ -1,121 +0,0 @@ -exports[`console > get_console_message > applies source maps to stack traces of Error object (with cause) console.log arguments 1`] = ` -# test response -ID: 1 -Message: log> foo failed Error: bar failed -### Arguments -Arg #0: foo failed -Arg #1: Error: bar failed -at foo (main.js:10:11) -at Iife (main.js:16:5) -at (main.js:14:1) -Caused by: Error: b00m! -at bar (main.js:3:9) -at foo (main.js:8:5) -at Iife (main.js:16:5) -at (main.js:14:1) -Note: line and column numbers use 1-based indexing -### Stack trace -at Iife (main.js:18:13) -at (main.js:14:1) -Note: line and column numbers use 1-based indexing -`; - -exports[`console > get_console_message > applies source maps to stack traces of Error object console.log arguments 1`] = ` -# test response -ID: 1 -Message: log> An error happened: Error: b00m! -### Arguments -Arg #0: An error happened: -Arg #1: Error: b00m! -at bar (main.js:3:9) -at foo (main.js:7:3) -at Iife (main.js:12:5) -at (main.js:10:1) -Note: line and column numbers use 1-based indexing -### Stack trace -at Iife (main.js:14:13) -at (main.js:10:1) -Note: line and column numbers use 1-based indexing -`; - -exports[`console > get_console_message > applies source maps to stack traces of console messages 1`] = ` -# test response -ID: 1 -Message: warn> hello world -### Arguments -Arg #0: hello world -### Stack trace -at bar (main.js:3:11) -at foo (main.js:7:3) -at Iife (main.js:11:3) -at (main.js:10:1) -Note: line and column numbers use 1-based indexing -`; - -exports[`console > get_console_message > applies source maps to stack traces of uncaught exceptions 1`] = ` -# test response -ID: 1 -Message: error> Uncaught Error: b00m! -### Stack trace -at bar (main.js:3:9) -at foo (main.js:7:3) -at Iife (main.js:11:3) -at (main.js:10:1) -Note: line and column numbers use 1-based indexing -`; - -exports[`console > get_console_message > applies source maps to stack traces of uncaught exceptions with cause 1`] = ` -# test response -ID: 1 -Message: error> Uncaught Error: foo failed -### Stack trace -at Iife (main.js:18:11) -at (main.js:14:1) -Caused by: Error: bar failed -at foo (main.js:10:11) -at Iife (main.js:16:5) -at (main.js:14:1) -Caused by: Error: b00m! -at bar (main.js:3:9) -at foo (main.js:8:5) -at Iife (main.js:16:5) -at (main.js:14:1) -Note: line and column numbers use 1-based indexing -`; - -exports[`console > get_console_message > issues type > gets issue details with node id parsing 1`] = ` -# test response -ID: 1 -Message: issue> An element doesn't have an autocomplete attribute - -A form field has an \`id\` or \`name\` attribute that the browser's autofill recognizes. However, it doesn't have an \`autocomplete\` attribute assigned. This might prevent the browser from correctly autofilling the form. - -To fix this issue, provide an \`autocomplete\` attribute. -Learn more: -[HTML attribute: autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete#values) -### Affected resources -uid=1_1 data={"violatingNodeAttribute":"name"} -`; - -exports[`console > get_console_message > issues type > gets issue details with request id parsing 1`] = ` -# test response -ID: -Message: issue> Ensure CORS response header values are valid - -A cross-origin resource sharing (CORS) request was blocked because of invalid or missing response headers of the request or the associated [preflight request](issueCorsPreflightRequest). - -To fix this issue, ensure the response to the CORS request and/or the associated [preflight request](issueCorsPreflightRequest) are not missing headers and use valid header values. - -Note that if an opaque response is sufficient, the request's mode can be set to \`no-cors\` to fetch the resource with CORS disabled; that way CORS headers are not required but the response content is inaccessible (opaque). -Learn more: -[Cross-Origin Resource Sharing (\`CORS\`)](https://web.dev/cross-origin-resource-sharing) -### Affected resources -reqid= data={"corsErrorStatus":{"corsError":"PreflightMissingAllowOriginHeader","failedParameter":""},"isWarning":false,"request":{"url":"http://hostname:port/data.json"},"initiatorOrigin":"","clientSecurityState":{"initiatorIsSecureContext":false,"initiatorIPAddressSpace":"Loopback","privateNetworkRequestPolicy":"BlockFromInsecureToMorePrivate"}} -`; - -exports[`console > list_console_messages > lists error objects 1`] = ` -# test response -## Console messages -Showing 1-1 of 1 (Page 1 of 1). -msgid=1 [error] Error: This is an error (1 args) -`; diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts deleted file mode 100644 index bb0034c74..000000000 --- a/tests/tools/console.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {before, describe, it} from 'node:test'; - -import {loadIssueDescriptions} from '../../src/issue-descriptions.js'; -import {McpResponse} from '../../src/McpResponse.js'; -import {DevTools} from '../../src/third_party/index.js'; -import { - getConsoleMessage, - listConsoleMessages, -} from '../../src/tools/console.js'; -import {serverHooks} from '../server.js'; -import {getTextContent, withMcpContext} from '../utils.js'; - -describe('console', () => { - before(async () => { - await loadIssueDescriptions(); - }); - describe('list_console_messages', () => { - it('list messages', async () => { - await withMcpContext(async (response, context) => { - await listConsoleMessages.handler({params: {}}, response, context); - assert.ok(response.includeConsoleData); - }); - }); - - it('lists error messages', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - await listConsoleMessages.handler({params: {}}, response, context); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok(textContent.includes('msgid=1 [error] This is an error')); - }); - }); - - it('lists error objects', async t => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - await listConsoleMessages.handler({params: {}}, response, context); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(textContent); - }); - }); - - it('work with primitive unhandled errors', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent(''); - await listConsoleMessages.handler({params: {}}, response, context); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok(textContent.includes('msgid=1 [error] Uncaught (0 args)')); - }); - }); - - describe('textFilter', () => { - it('returns only messages matching the text substring', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - await listConsoleMessages.handler( - {params: {textFilter: 'hello'}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok(textContent.includes('hello world')); - assert.ok(textContent.includes('hello error')); - assert.ok(!textContent.includes('goodbye world')); - }); - }); - - it('is case-insensitive', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - await listConsoleMessages.handler( - {params: {textFilter: 'hello'}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok(textContent.includes('Hello World')); - assert.ok(!textContent.includes('other message')); - }); - }); - - it('returns no messages when nothing matches', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - await listConsoleMessages.handler( - {params: {textFilter: 'nonexistent'}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok(textContent.includes('No console messages found.')); - }); - }); - }); - - describe('combined filters', () => { - it('applies textFilter together with types filter', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - await listConsoleMessages.handler( - {params: {types: ['error'], textFilter: 'hello'}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok(textContent.includes('hello error')); - assert.ok(!textContent.includes('hello log')); - assert.ok(!textContent.includes('goodbye log')); - }); - }); - }); - - describe('issues', () => { - it('lists issues', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - const issuePromise = new Promise(resolve => { - page.once('issue', () => { - resolve(); - }); - }); - await page.setContent(''); - await issuePromise; - await listConsoleMessages.handler({params: {}}, response, context); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok( - textContent.includes( - `msgid=1 [issue] An element doesn't have an autocomplete attribute (count: 1)`, - ), - ); - }); - }); - - it('lists issues after a page reload', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - const issuePromise = new Promise(resolve => { - page.once('issue', () => { - resolve(); - }); - }); - - await page.setContent(''); - await issuePromise; - await listConsoleMessages.handler({params: {}}, response, context); - { - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok( - textContent.includes( - `msgid=1 [issue] An element doesn't have an autocomplete attribute (count: 1)`, - ), - ); - } - - const anotherIssuePromise = new Promise(resolve => { - page.once('issue', () => { - resolve(); - }); - }); - await page.reload(); - await page.setContent(''); - await anotherIssuePromise; - { - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok( - textContent.includes( - `msgid=2 [issue] An element doesn't have an autocomplete attribute (count: 1)`, - ), - ); - } - }); - }); - }); - }); - - describe('get_console_message', () => { - const server = serverHooks(); - - it('gets a specific console message', async () => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.setContent( - '', - ); - // The list is needed to populate the console messages in the context. - await listConsoleMessages.handler({params: {}}, response, context); - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse.content[0]); - assert.ok( - textContent.includes('msgid=1 [error] This is an error'), - 'Should contain console message body', - ); - }); - }); - - describe('issues type', () => { - it('gets issue details with node id parsing', async t => { - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - const issuePromise = new Promise(resolve => { - page.once('issue', () => { - resolve(); - }); - }); - await page.setContent(''); - await context.createTextSnapshot(); - await issuePromise; - await listConsoleMessages.handler({params: {}}, response, context); - const response2 = new McpResponse(); - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response2, - context, - ); - const formattedResponse = await response2.handle('test', context); - t.assert.snapshot?.(getTextContent(formattedResponse.content[0])); - }); - }); - it('gets issue details with request id parsing', async t => { - server.addRoute('/data.json', (_req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.statusCode = 200; - res.end(JSON.stringify({data: 'test data'})); - }); - - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - const issuePromise = new Promise(resolve => { - page.once('issue', () => { - resolve(); - }); - }); - - const url = server.getRoute('/data.json'); - await page.setContent(` - - `); - await context.createTextSnapshot(); - await issuePromise; - const messages = context.getConsoleData(); - let issueMsg; - for (const message of messages) { - if (message instanceof DevTools.AggregatedIssue) { - issueMsg = message; - break; - } - } - assert.ok(issueMsg); - const id = context.getConsoleMessageStableId(issueMsg); - assert.ok(id); - await listConsoleMessages.handler( - {params: {types: ['issue']}}, - response, - context, - ); - const response2 = new McpResponse(); - await getConsoleMessage.handler( - {params: {msgid: id}}, - response2, - context, - ); - const formattedResponse = await response2.handle('test', context); - const rawText = getTextContent(formattedResponse.content[0]); - const sanitizedText = rawText - .replaceAll(/ID: \d+/g, 'ID: ') - .replaceAll(/reqid=\d+/g, 'reqid=') - .replaceAll(/localhost:\d+/g, 'hostname:port'); - t.assert.snapshot?.(sanitizedText); - }); - }); - }); - - it('applies source maps to stack traces of console messages', async t => { - server.addRoute('/main.min.js', (_req, res) => { - res.setHeader('Content-Type', 'text/javascript'); - res.statusCode = 200; - res.end(`function n(){console.warn("hello world")}function o(){n()}(function n(){o()})(); - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJjb25zb2xlIiwid2FybiIsImZvbyIsIklpZmUiXSwic291cmNlcyI6WyIuL21haW4uanMiXSwic291cmNlc0NvbnRlbnQiOlsiXG5mdW5jdGlvbiBiYXIoKSB7XG4gIGNvbnNvbGUud2FybignaGVsbG8gd29ybGQnKTtcbn1cblxuZnVuY3Rpb24gZm9vKCkge1xuICBiYXIoKTtcbn1cblxuKGZ1bmN0aW9uIElpZmUoKSB7XG4gIGZvbygpO1xufSkoKTtcblxuIl0sIm1hcHBpbmdzIjoiQUFDQSxTQUFTQSxJQUNQQyxRQUFRQyxLQUFLLGNBQ2YsQ0FFQSxTQUFTQyxJQUNQSCxHQUNGLEVBRUEsU0FBVUksSUFDUkQsR0FDRCxFQUZEIiwiaWdub3JlTGlzdCI6W119 - `); - }); - server.addHtmlRoute( - '/index.html', - ``, - ); - - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.goto(server.getRoute('/index.html')); - - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const rawText = getTextContent(formattedResponse.content[0]); - - t.assert.snapshot?.(rawText); - }); - }); - - it('applies source maps to stack traces of uncaught exceptions', async t => { - server.addRoute('/main.min.js', (_req, res) => { - res.setHeader('Content-Type', 'text/javascript'); - res.statusCode = 200; - res.end(`function n(){throw new Error("b00m!")}function o(){n()}(function n(){o()})(); - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJFcnJvciIsImZvbyIsIklpZmUiXSwic291cmNlcyI6WyIuL21haW4uanMiXSwic291cmNlc0NvbnRlbnQiOlsiXG5mdW5jdGlvbiBiYXIoKSB7XG4gIHRocm93IG5ldyBFcnJvcignYjAwbSEnKTtcbn1cblxuZnVuY3Rpb24gZm9vKCkge1xuICBiYXIoKTtcbn1cblxuKGZ1bmN0aW9uIElpZmUoKSB7XG4gIGZvbygpO1xufSkoKTtcblxuIl0sIm1hcHBpbmdzIjoiQUFDQSxTQUFTQSxJQUNQLE1BQU0sSUFBSUMsTUFBTSxRQUNsQixDQUVBLFNBQVNDLElBQ1BGLEdBQ0YsRUFFQSxTQUFVRyxJQUNSRCxHQUNELEVBRkQiLCJpZ25vcmVMaXN0IjpbXX0= - `); - }); - server.addHtmlRoute( - '/index.html', - ``, - ); - - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.goto(server.getRoute('/index.html')); - - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const rawText = getTextContent(formattedResponse.content[0]); - - t.assert.snapshot?.(rawText); - }); - }); - - it('applies source maps to stack traces of Error object console.log arguments', async t => { - server.addRoute('/main.min.js', (_req, res) => { - res.setHeader('Content-Type', 'text/javascript'); - res.statusCode = 200; - res.end(`function n(){throw new Error("b00m!")}function o(){n()}(function n(){try{o()}catch(n){console.log("An error happened:",n)}})(); - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJFcnJvciIsImZvbyIsIklpZmUiLCJlIiwiY29uc29sZSIsImxvZyJdLCJzb3VyY2VzIjpbIi4vbWFpbi5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJcbmZ1bmN0aW9uIGJhcigpIHtcbiAgdGhyb3cgbmV3IEVycm9yKCdiMDBtIScpO1xufVxuXG5mdW5jdGlvbiBmb28oKSB7XG4gIGJhcigpO1xufVxuXG4oZnVuY3Rpb24gSWlmZSgpIHtcbiAgdHJ5IHtcbiAgICBmb28oKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnNvbGUubG9nKCdBbiBlcnJvciBoYXBwZW5lZDonLCBlKTtcbiAgfVxufSkoKTtcblxuIl0sIm1hcHBpbmdzIjoiQUFDQSxTQUFTQSxJQUNQLE1BQU0sSUFBSUMsTUFBTSxRQUNsQixDQUVBLFNBQVNDLElBQ1BGLEdBQ0YsRUFFQSxTQUFVRyxJQUNSLElBQ0VELEdBQ0YsQ0FBRSxNQUFPRSxHQUNQQyxRQUFRQyxJQUFJLHFCQUFzQkYsRUFDcEMsQ0FDRCxFQU5EIiwiaWdub3JlTGlzdCI6W119 - `); - }); - server.addHtmlRoute( - '/index.html', - ``, - ); - - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.goto(server.getRoute('/index.html')); - - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const rawText = getTextContent(formattedResponse.content[0]); - - t.assert.snapshot?.(rawText); - }); - }); - - it('applies source maps to stack traces of uncaught exceptions with cause', async t => { - server.addRoute('/main.min.js', (_req, res) => { - res.setHeader('Content-Type', 'text/javascript'); - res.statusCode = 200; - res.end(`function r(){throw new Error("b00m!")}function o(){try{r()}catch(r){throw new Error("bar failed",{cause:r})}}(function r(){try{o()}catch(r){throw new Error("foo failed",{cause:r})}})(); - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJFcnJvciIsImZvbyIsImUiLCJjYXVzZSIsIklpZmUiXSwic291cmNlcyI6WyIuL21haW4uanMiXSwic291cmNlc0NvbnRlbnQiOlsiXG5mdW5jdGlvbiBiYXIoKSB7XG4gIHRocm93IG5ldyBFcnJvcignYjAwbSEnKTtcbn1cblxuZnVuY3Rpb24gZm9vKCkge1xuICB0cnkge1xuICAgIGJhcigpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKCdiYXIgZmFpbGVkJywgeyBjYXVzZTogZSB9KTtcbiAgfVxufVxuXG4oZnVuY3Rpb24gSWlmZSgpIHtcbiAgdHJ5IHtcbiAgICBmb28oKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIHRocm93IG5ldyBFcnJvcignZm9vIGZhaWxlZCcsIHsgY2F1c2U6IGUgfSk7XG4gIH1cbn0pKCk7XG5cbiJdLCJtYXBwaW5ncyI6IkFBQ0EsU0FBU0EsSUFDUCxNQUFNLElBQUlDLE1BQU0sUUFDbEIsQ0FFQSxTQUFTQyxJQUNQLElBQ0VGLEdBQ0YsQ0FBRSxNQUFPRyxHQUNQLE1BQU0sSUFBSUYsTUFBTSxhQUFjLENBQUVHLE1BQU9ELEdBQ3pDLENBQ0YsRUFFQSxTQUFVRSxJQUNSLElBQ0VILEdBQ0YsQ0FBRSxNQUFPQyxHQUNQLE1BQU0sSUFBSUYsTUFBTSxhQUFjLENBQUVHLE1BQU9ELEdBQ3pDLENBQ0QsRUFORCIsImlnbm9yZUxpc3QiOltdfQ== - `); - }); - server.addHtmlRoute( - '/index.html', - ``, - ); - - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.goto(server.getRoute('/index.html')); - - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const rawText = getTextContent(formattedResponse.content[0]); - - t.assert.snapshot?.(rawText); - }); - }); - - it('applies source maps to stack traces of Error object (with cause) console.log arguments', async t => { - server.addRoute('/main.min.js', (_req, res) => { - res.setHeader('Content-Type', 'text/javascript'); - res.statusCode = 200; - res.end(`function o(){throw new Error("b00m!")}function r(){try{o()}catch(o){throw new Error("bar failed",{cause:o})}}(function o(){try{r()}catch(o){console.log("foo failed",o)}})(); - //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJFcnJvciIsImZvbyIsImUiLCJjYXVzZSIsIklpZmUiLCJjb25zb2xlIiwibG9nIl0sInNvdXJjZXMiOlsiLi9tYWluLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIlxuZnVuY3Rpb24gYmFyKCkge1xuICB0aHJvdyBuZXcgRXJyb3IoJ2IwMG0hJyk7XG59XG5cbmZ1bmN0aW9uIGZvbygpIHtcbiAgdHJ5IHtcbiAgICBiYXIoKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIHRocm93IG5ldyBFcnJvcignYmFyIGZhaWxlZCcsIHsgY2F1c2U6IGUgfSk7XG4gIH1cbn1cblxuKGZ1bmN0aW9uIElpZmUoKSB7XG4gIHRyeSB7XG4gICAgZm9vKCk7XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmxvZygnZm9vIGZhaWxlZCcsIGUpO1xuICB9XG59KSgpO1xuXG4iXSwibWFwcGluZ3MiOiJBQUNBLFNBQVNBLElBQ1AsTUFBTSxJQUFJQyxNQUFNLFFBQ2xCLENBRUEsU0FBU0MsSUFDUCxJQUNFRixHQUNGLENBQUUsTUFBT0csR0FDUCxNQUFNLElBQUlGLE1BQU0sYUFBYyxDQUFFRyxNQUFPRCxHQUN6QyxDQUNGLEVBRUEsU0FBVUUsSUFDUixJQUNFSCxHQUNGLENBQUUsTUFBT0MsR0FDUEcsUUFBUUMsSUFBSSxhQUFjSixFQUM1QixDQUNELEVBTkQiLCJpZ25vcmVMaXN0IjpbXX0= - `); - }); - server.addHtmlRoute( - '/index.html', - ``, - ); - - await withMcpContext(async (response, context) => { - const page = await context.newPage(); - await page.goto(server.getRoute('/index.html')); - - await getConsoleMessage.handler( - {params: {msgid: 1}}, - response, - context, - ); - const formattedResponse = await response.handle('test', context); - const rawText = getTextContent(formattedResponse.content[0]); - - t.assert.snapshot?.(rawText); - }); - }); - }); -}); diff --git a/tests/tools/fixtures/extension/manifest.json b/tests/tools/fixtures/extension/manifest.json deleted file mode 100644 index fc0304f0b..000000000 --- a/tests/tools/fixtures/extension/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "manifest_version": 3, - "name": "Test Extension", - "version": "1.0", - "action": { - "default_popup": "popup.html" - } -} diff --git a/tests/tools/fixtures/extension/popup.html b/tests/tools/fixtures/extension/popup.html deleted file mode 100644 index c5c00a395..000000000 --- a/tests/tools/fixtures/extension/popup.html +++ /dev/null @@ -1,6 +0,0 @@ - - - -

Test Popup

- - diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts deleted file mode 100644 index 1c57798e1..000000000 --- a/tests/tools/input.test.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it} from 'node:test'; - -import {McpResponse} from '../../src/McpResponse.js'; -import { - click, - hover, - type, - drag, - hotkeyTool, - scroll, -} from '../../src/tools/input.js'; -import {parseKey} from '../../src/utils/keyboard.js'; -import {serverHooks} from '../server.js'; -import {html, withMcpContext} from '../utils.js'; - -describe('input', () => { - const server = serverHooks(); - - describe('click', () => { - it('clicks', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await context.createTextSnapshot(); - await click.handler( - { - params: { - uid: '1_1', - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully clicked on the element', - ); - assert.ok(response.includeSnapshot); - assert.ok(await page.$('text/clicked')); - }); - }); - it('double clicks', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await context.createTextSnapshot(); - await click.handler( - { - params: { - uid: '1_1', - dblClick: true, - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully double clicked on the element', - ); - assert.ok(response.includeSnapshot); - assert.ok(await page.$('text/dblclicked')); - }); - }); - it('waits for navigation', async () => { - const resolveNavigation = Promise.withResolvers(); - server.addHtmlRoute( - '/link', - html`Navigate page`, - ); - server.addRoute('/navigated', async (_req, res) => { - await resolveNavigation.promise; - res.write(html`
I was navigated
`); - res.end(); - }); - - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.goto(server.getRoute('/link')); - await context.createTextSnapshot(); - const clickPromise = click.handler( - { - params: { - uid: '1_1', - }, - }, - response, - context, - ); - const [t1, t2] = await Promise.all([ - clickPromise.then(() => Date.now()), - new Promise(res => { - setTimeout(() => { - resolveNavigation.resolve(); - res(Date.now()); - }, 300); - }), - ]); - - assert(t1 > t2, 'Waited for navigation'); - }); - }); - - it('waits for stable DOM', async () => { - server.addHtmlRoute( - '/unstable', - html` - - - `, - ); - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.goto(server.getRoute('/unstable')); - await context.createTextSnapshot(); - const handlerResolveTime = await click - .handler( - { - params: { - uid: '1_1', - }, - }, - response, - context, - ) - .then(() => Date.now()); - const buttonChangeTime = await page.evaluate(() => { - const button = document.querySelector('button'); - return Number(button?.textContent); - }); - - assert(handlerResolveTime > buttonChangeTime, 'Waited for navigation'); - }); - }); - - it('does not include snapshot by default', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await context.createTextSnapshot(); - await click.handler( - { - params: { - uid: '1_1', - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully clicked on the element', - ); - assert.strictEqual(response.snapshotParams, undefined); - }); - }); - - it('includes snapshot if includeSnapshot is true', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await context.createTextSnapshot(); - await click.handler( - { - params: { - uid: '1_1', - includeSnapshot: true, - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully clicked on the element', - ); - assert.notStrictEqual(response.snapshotParams, undefined); - }); - }); - }); - - describe('hover', () => { - it('hovers', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await context.createTextSnapshot(); - await hover.handler( - { - params: { - uid: '1_1', - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully hovered over the element', - ); - assert.ok(response.includeSnapshot); - assert.ok(await page.$('text/hovered')); - }); - }); - }); - - describe('type', () => { - it('fills out an input', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent(html``); - await context.createTextSnapshot(); - await type.handler( - { - params: { - uid: '1_1', - value: 'test', - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully filled out the element', - ); - assert.ok(response.includeSnapshot); - assert.ok(await page.$('text/test')); - }); - }); - - it('fills out a select by text', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent( - html``, - ); - await context.createTextSnapshot(); - await type.handler( - { - params: { - uid: '1_1', - value: 'two', - }, - }, - response, - context, - ); - assert.strictEqual( - response.responseLines[0], - 'Successfully filled out the element', - ); - assert.ok(response.includeSnapshot); - const selectedValue = await page.evaluate( - () => document.querySelector('select')!.value, - ); - assert.strictEqual(selectedValue, 'v2'); - }); - }); - - it('fills out a textarea with long text', async () => { - await withMcpContext(async (response, context) => { - const page = context.getSelectedPage(); - await page.setContent(html`