diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..e888304 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "devtools-for-agents", + "description": "Help your agent build, debug, and verify your code correctly. With Chrome DevTools for agents, your AI agent can interact with the Chrome browser to test code, emulate users, and catch bugs using Chrome DevTools’ capabilities before shipping.", + "version": "1.1.1", + "author": { + "name": "Google Chrome" + }, + "logo": "https://github.com/ChromeDevTools/devtools-logo/blob/master/logos/svg/chrome-devtools-square-responsive.svg", + "homepage": "https://cursor.com/marketplace/devtools-for-agents", + "repository": "https://github.com/ChromeDevTools/chrome-devtools-mcp", + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "chrome-devtools-mcp@1.1.1" + ] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/02-feature.yml b/.github/ISSUE_TEMPLATE/02-feature.yml index 7844336..0bd648b 100644 --- a/.github/ISSUE_TEMPLATE/02-feature.yml +++ b/.github/ISSUE_TEMPLATE/02-feature.yml @@ -1,5 +1,5 @@ name: Feature -description: Suggest an idea for for chrome-devtools-mcp +description: Suggest an idea for chrome-devtools-mcp title: '' type: 'Feature' labels: diff --git a/.github/ISSUE_TEMPLATE/03-task.yml b/.github/ISSUE_TEMPLATE/03-task.yml index 16dcf90..6bf8e45 100644 --- a/.github/ISSUE_TEMPLATE/03-task.yml +++ b/.github/ISSUE_TEMPLATE/03-task.yml @@ -1,5 +1,5 @@ name: Task -description: Work tracking for mainainers only! +description: Work tracking for maintainers only! title: '[Task]:' type: 'Task' body: diff --git a/.github/plugin/plugin.json b/.github/plugin/plugin.json index f1ebdff..dab58e3 100644 --- a/.github/plugin/plugin.json +++ b/.github/plugin/plugin.json @@ -1,12 +1,12 @@ { "name": "chrome-devtools-mcp", - "version": "0.26.0", + "version": "1.1.1", "description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer", "mcpServers": { "chrome-devtools": { "command": "npx", "args": [ - "chrome-devtools-mcp@latest" + "chrome-devtools-mcp@1.1.1" ] } } diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e5975a7..b9afd88 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -21,10 +21,10 @@ jobs: - windows-latest - macos-latest node: - - 20 - 22 - - 23 - 24 + - 26 + steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -40,6 +40,12 @@ jobs: - name: Install dependencies shell: bash run: npm ci + env: + PUPPETEER_SKIP_DOWNLOAD: true + + - name: Install browser + shell: bash + run: npx puppeteer browsers install chrome - name: Build run: npm run bundle diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 017b717..0000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "chrome-devtools": { - "command": "npx", - "args": ["chrome-devtools-mcp@latest"] - } - } -} diff --git a/.prettierignore b/.prettierignore index 2e975d3..86703e3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,6 @@ src/third_party/lighthouse-devtools-mcp-bundle.js # Release-please formatting brakes CI checks .claude-plugin/plugin.json +.cursor-plugin/plugin.json .github/plugin/plugin.json +gemini-extension.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8621649..69883ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.2.2](https://github.com/operasoftware/opera-devtools-mcp/compare/opera-devtools-mcp-v0.2.0...opera-devtools-mcp-v0.2.2) (2026-05-28) + +### Upstream intake + +Rebased onto [chrome-devtools-mcp 1.1.1](https://github.com/ChromeDevTools/chrome-devtools-mcp/blob/main/CHANGELOG.md) (from 0.26.0). See upstream changelog for the full list of upstream changes. + ## [0.2.0](https://github.com/operasoftware/opera-devtools-mcp/compare/opera-devtools-mcp-v0.1.1...opera-devtools-mcp-v0.2.0) (2026-05-13) ### Upstream intake diff --git a/README.md b/README.md index 9bcbd03..6e8359e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ experience data. To disable this, run with the `--no-performance-crux` flag. ## Requirements -- [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version. +- [Node.js](https://nodejs.org/) [LTS](https://github.com/nodejs/Release#release-schedule) version. - A Chromium-based browser (Chrome or Opera Neon). - [npm](https://www.npmjs.com/) @@ -192,11 +192,12 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`take_snapshot`](docs/tool-reference.md#take_snapshot) - [`screencast_start`](docs/tool-reference.md#screencast_start) - [`screencast_stop`](docs/tool-reference.md#screencast_stop) -- **Memory** (4 tools) - - [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot) - - [`get_memory_snapshot_details`](docs/tool-reference.md#get_memory_snapshot_details) - - [`get_nodes_by_class`](docs/tool-reference.md#get_nodes_by_class) - - [`load_memory_snapshot`](docs/tool-reference.md#load_memory_snapshot) +- **Memory** (5 tools) + - [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot) + - [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes) + - [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details) + - [`get_heapsnapshot_retainers`](docs/tool-reference.md#get_heapsnapshot_retainers) + - [`get_heapsnapshot_summary`](docs/tool-reference.md#get_heapsnapshot_summary) - **Opera** (4 tools) - [`opera_chat`](docs/tool-reference.md#opera_chat) - [`opera_do`](docs/tool-reference.md#opera_do) @@ -278,10 +279,30 @@ The Opera DevTools MCP server supports the following configuration options: If enabled, ignores errors relative to self-signed and expired certificates. Use with caution. - **Type:** boolean +- **`--experimentalPageIdRouting`/ `--experimental-page-id-routing`** + Whether to expose pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions). + - **Type:** boolean + +- **`--experimentalDevtools`/ `--experimental-devtools`** + Whether to enable automation over DevTools targets + - **Type:** boolean + - **`--experimentalVision`/ `--experimental-vision`** Whether to enable coordinate-based tools such as click_at(x,y). Usually requires a computer-use model able to produce accurate coordinates by looking at screenshots. - **Type:** boolean +- **`--experimentalMemory`/ `--experimental-memory`** + Whether to enable experimental memory tools. + - **Type:** boolean + +- **`--experimentalStructuredContent`/ `--experimental-structured-content`** + Whether to output structured formatted content. + - **Type:** boolean + +- **`--experimentalIncludeAllPages`/ `--experimental-include-all-pages`** + Whether to include all kinds of pages such as webviews or background pages as pages. + - **Type:** boolean + - **`--experimentalScreencast`/ `--experimental-screencast`** Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH. - **Type:** boolean @@ -342,7 +363,7 @@ The Opera DevTools MCP server supports the following configuration options: - **Type:** boolean - **`--redactNetworkHeaders`/ `--redact-network-headers`** - If true, redacts some of the network headers considered senstive before returning to the client. + If true, redacts some of the network headers considered sensitive before returning to the client. - **Type:** boolean - **Default:** `false` @@ -384,6 +405,34 @@ Pass them via the `args` property in the JSON configuration. For example: ## Concepts +### Concurrent sessions + +Most MCP clients start one Chrome DevTools MCP server per conversation. If your +client shares a single server instance across concurrent agents or subagents, +start the server with `--experimentalPageIdRouting`. This exposes `pageId` on +page-scoped tools so each agent can route tool calls to the tab it is working +with. + +```json +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--experimentalPageIdRouting" + ] + } + } +} +``` + +If you run multiple independent MCP client sessions and want each session to +launch its own temporary Chrome profile, also pass `--isolated`. This avoids +sharing the default Chrome DevTools MCP user data directory between those +server instances. + ### User data directory `opera-devtools-mcp` starts a browser instance using the following user data directory: diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 2985f4a..0ee0d5b 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -39,11 +39,12 @@ - [`take_snapshot`](#take_snapshot) - [`screencast_start`](#screencast_start) - [`screencast_stop`](#screencast_stop) -- **[Memory](#memory)** (4 tools) - - [`take_memory_snapshot`](#take_memory_snapshot) - - [`get_memory_snapshot_details`](#get_memory_snapshot_details) - - [`get_nodes_by_class`](#get_nodes_by_class) - - [`load_memory_snapshot`](#load_memory_snapshot) +- **[Memory](#memory)** (5 tools) + - [`take_heapsnapshot`](#take_heapsnapshot) + - [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes) + - [`get_heapsnapshot_details`](#get_heapsnapshot_details) + - [`get_heapsnapshot_retainers`](#get_heapsnapshot_retainers) + - [`get_heapsnapshot_summary`](#get_heapsnapshot_summary) - **[Opera](#opera)** (4 tools) - [`opera_chat`](#opera_chat) - [`opera_do`](#opera_do) @@ -260,7 +261,8 @@ - **colorScheme** (enum: "dark", "light", "auto") _(optional)_: [`Emulate`](#emulate) the dark or the light mode. Set to "auto" to reset to the default. - **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling -- **geolocation** (string) _(optional)_: Geolocation (`<latitude>x<longitude>`) to [`emulate`](#emulate). Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override. +- **extraHttpHeaders** (string) _(optional)_: Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers. +- **geolocation** (string) _(optional)_: Geolocation (`<latitude>,<longitude>`) to [`emulate`](#emulate). Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override. - **networkConditions** (enum: "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Omit to disable throttling. - **userAgent** (string) _(optional)_: User agent to [`emulate`](#emulate). Set to empty string to clear the user agent override. - **viewport** (string) _(optional)_: [`Emulate`](#emulate) device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to [`emulate`](#emulate) mobile devices. 'landscape' to [`emulate`](#emulate) landscape mode. @@ -361,6 +363,7 @@ so returned values have to be JSON-serializable. - **args** (array) _(optional)_: An optional list of arguments to pass to the function. - **dialogAction** (string) _(optional)_: Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept. +- **filePath** (string) _(optional)_: The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline. --- @@ -446,7 +449,7 @@ in the DevTools Elements panel (if any). ## Memory -### `take_memory_snapshot` +### `take_heapsnapshot` **Description:** Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks. @@ -456,7 +459,20 @@ in the DevTools Elements panel (if any). --- -### `get_memory_snapshot_details` +### `get_heapsnapshot_class_nodes` + +**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --experimentalMemory=true) + +**Parameters:** + +- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. +- **id** (number) **(required)**: The ID for the class, obtained from details. +- **pageIdx** (number) _(optional)_: The page index for pagination. +- **pageSize** (number) _(optional)_: The page size for pagination. + +--- + +### `get_heapsnapshot_details` **Description:** Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates. (requires flag: --experimentalMemory=true) @@ -468,20 +484,20 @@ in the DevTools Elements panel (if any). --- -### `get_nodes_by_class` +### `get_heapsnapshot_retainers` -**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs. (requires flag: --experimentalMemory=true) +**Description:** Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true) **Parameters:** - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. -- **uid** (number) **(required)**: The unique UID for the class, obtained from aggregates listing. +- **nodeId** (number) **(required)**: The node ID to get retainers for. - **pageIdx** (number) _(optional)_: The page index for pagination. - **pageSize** (number) _(optional)_: The page size for pagination. --- -### `load_memory_snapshot` +### `get_heapsnapshot_summary` **Description:** Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true) diff --git a/gemini-extension.json b/gemini-extension.json index 10c45cb..9363b2e 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,10 +1,12 @@ { "name": "chrome-devtools-mcp", - "version": "latest", + "version": "1.1.1", "mcpServers": { "chrome-devtools": { "command": "npx", - "args": ["chrome-devtools-mcp@latest"] + "args": [ + "chrome-devtools-mcp@1.1.1" + ] } } } diff --git a/package-lock.json b/package-lock.json index abf0d4d..b10213d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opera-devtools-mcp", - "version": "0.2.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opera-devtools-mcp", - "version": "0.2.0", + "version": "0.2.2", "license": "Apache-2.0", "bin": { "opera-devtools": "build/src/bin/opera-devtools.js", @@ -28,7 +28,7 @@ "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", - "chrome-devtools-frontend": "1.0.1628368", + "chrome-devtools-frontend": "1.0.1635648", "core-js": "3.49.0", "debug": "4.4.3", "eslint": "^9.35.0", @@ -37,8 +37,8 @@ "globals": "^17.0.0", "lighthouse": "13.3.0", "prettier": "^3.6.2", - "puppeteer": "24.43.0", - "rollup": "4.60.3", + "puppeteer": "25.1.0", + "rollup": "4.60.4", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.6.0", "semver": "^7.7.4", @@ -53,31 +53,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -384,9 +359,9 @@ } }, "node_modules/@google/genai": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.2.0.tgz", - "integrity": "sha512-RA3cuaKLldWTrpxhNm2nAe0Oez/UEuoH11jFjPzbYL7TSP83lMk+ic7pQKZ4ekJdZfnIdgxWl1DwJoUwxmvWGQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.6.0.tgz", + "integrity": "sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1315,25 +1290,28 @@ "license": "BSD-3-Clause" }, "node_modules/@puppeteer/browsers": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.1.tgz", - "integrity": "sha512-zmS4RTK9fbrc++WlAJhxYbfz3IjDeOmkK/CwwbLmk7ydfS9e2CiEeRJHEPvjDVElO/bwXbidwGA37Bsm6LzCnQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz", + "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", + "modern-tar": "^0.7.6", "yargs": "^17.7.2" }, "bin": { - "browsers": "lib/cjs/main-cli.js" + "browsers": "lib/main-cli.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } } }, "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { @@ -1540,9 +1518,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -1554,9 +1532,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -1568,9 +1546,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -1582,9 +1560,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -1596,9 +1574,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -1610,9 +1588,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -1624,9 +1602,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -1638,9 +1616,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -1652,9 +1630,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -1666,9 +1644,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -1680,9 +1658,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -1694,9 +1672,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -1708,9 +1686,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -1722,9 +1700,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -1736,9 +1714,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -1750,9 +1728,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -1764,9 +1742,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -1778,9 +1756,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -1792,9 +1770,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -1806,9 +1784,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -1820,9 +1798,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -1834,9 +1812,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -1848,9 +1826,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -1862,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -1876,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -2059,13 +2037,6 @@ "eslint": "^9.0.0 || ^10.0.0" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2153,13 +2124,13 @@ } }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/pg": { @@ -2256,29 +2227,18 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2297,16 +2257,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2322,14 +2282,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -2344,14 +2304,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2362,9 +2322,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -2379,15 +2339,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2404,9 +2364,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -2418,16 +2378,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2485,16 +2445,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2509,13 +2469,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3102,19 +3062,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3162,21 +3109,6 @@ "node": ">=4" } }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3184,104 +3116,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3303,16 +3137,6 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -3358,16 +3182,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3463,9 +3277,9 @@ } }, "node_modules/chrome-devtools-frontend": { - "version": "1.0.1628368", - "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.1628368.tgz", - "integrity": "sha512-SrLlvkwU/QgJ3bDbtbLj+j3LUO7AnlDaYoCA95M65CgCrQCWxXK1xYIHMzWrIusYTp9TSfxOZRm7MGordpQotw==", + "version": "1.0.1635648", + "resolved": "https://registry.npmjs.org/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.1635648.tgz", + "integrity": "sha512-TO3O+y7KgV+ZGbQSXJptrwke2gBIVh7+4ImEuFunm54tJRLfXg7N/D6hsR5VL5jTDXe9kLEmoWVqRoZCqEYHhg==", "dev": true, "license": "BSD-3-Clause" }, @@ -3489,15 +3303,18 @@ } }, "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, "peerDependencies": { "devtools-protocol": "*" } @@ -3654,33 +3471,6 @@ "node": ">= 0.10" } }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3703,16 +3493,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3855,21 +3635,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3881,9 +3646,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1608973", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", - "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "version": "0.0.1624250", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", + "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", "dev": true, "license": "BSD-3-Clause" }, @@ -3982,16 +3747,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -4029,26 +3784,6 @@ "node": ">=8" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -4228,28 +3963,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -4580,20 +4293,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -4657,16 +4356,6 @@ "node": ">= 0.6" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4760,27 +4449,6 @@ "dev": true, "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4788,13 +4456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4826,16 +4487,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5183,22 +4834,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5230,21 +4865,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5507,20 +5127,6 @@ "node": ">=6.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -5682,13 +5288,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -6208,13 +5807,6 @@ "node": ">=12" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -6245,13 +5837,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6537,12 +6122,18 @@ "node": ">=12" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } }, "node_modules/locate-path": { "version": "6.0.0", @@ -6595,16 +6186,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -6725,6 +6306,16 @@ "dev": true, "license": "MIT" }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -6782,16 +6373,6 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7072,40 +6653,6 @@ "node": ">=8" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -7139,25 +6686,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7230,13 +6758,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/perf-regexes": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/perf-regexes/-/perf-regexes-1.0.1.tgz", @@ -7281,13 +6802,6 @@ "node": ">=4" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -7390,16 +6904,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/protobufjs": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", @@ -7439,44 +6943,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7488,50 +6954,49 @@ } }, "node_modules/puppeteer": { - "version": "24.43.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.0.tgz", - "integrity": "sha512-DRnMFz+J3s4lFUQcjqKl0/7h0jzlCZuUFU9lNjtKrnMl5WI1RwCaIItpHVu9empuPyUreYueN0sUW3/pnfdqsg==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz", + "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.1", - "chromium-bidi": "14.0.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1608973", - "puppeteer-core": "24.43.0", + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.1.0", "typed-query-selector": "^2.12.2" }, "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" + "puppeteer": "lib/puppeteer/node/cli.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/puppeteer-core": { - "version": "24.43.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.0.tgz", - "integrity": "sha512-cCRNXsUlhyPoKDz6+TiSpfZpRS3mD6Y1YFKhkdr6ik6TMfuJb7fAtXq9ThUFc4sphxObDk3BuAvdxc1Y6YOnqQ==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", + "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.1", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1608973", + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.20.0" + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7727,9 +7192,9 @@ } }, "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -7743,31 +7208,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -7929,9 +7394,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -8190,58 +7655,6 @@ "node": ">=4.2" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", @@ -8365,18 +7778,6 @@ "node": ">= 0.4" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -8606,54 +8007,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "streamx": "^2.12.5" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/third-party-web": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.29.0.tgz", @@ -8896,16 +8249,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", - "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8939,9 +8292,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -9035,9 +8388,9 @@ } }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", "dev": true, "license": "Apache-2.0" }, @@ -9266,9 +8619,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -9348,17 +8701,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index e3468aa..0384099 100644 --- a/package.json +++ b/package.json @@ -9,28 +9,28 @@ }, "main": "./build/src/index.js", "scripts": { - "cli:generate": "node --experimental-strip-types scripts/generate-cli.ts", + "cli:generate": "node scripts/generate-cli.ts", "clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"", - "bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\" && node --experimental-strip-types scripts/append-lighthouse-notices.ts", - "build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts", + "bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\" && node scripts/append-lighthouse-notices.ts", + "build": "tsc && node scripts/post-build.ts", "typecheck": "tsc --noEmit", "format": "eslint --cache --fix . && prettier --write --cache .", "check-format": "eslint --cache . && prettier --check --cache .;", "gen": "npm run build && npm run docs:generate && npm run cli:generate && npm run update-tool-call-metrics && npm run format", - "docs:generate": "node --experimental-strip-types scripts/generate-docs.ts", + "docs:generate": "node scripts/generate-docs.ts", "start": "npm run build && node build/src/bin/chrome-devtools-mcp.js", "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/bin/chrome-devtools-mcp.js", "test": "npm run build && node scripts/test.mjs", "test:no-build": "node scripts/test.mjs", "test:only": "npm run build && node scripts/test.mjs --test-only", "test:update-snapshots": "npm run build && node scripts/test.mjs --test-update-snapshots", - "prepare": "node --experimental-strip-types scripts/prepare.ts", - "verify-server-json-version": "node --experimental-strip-types scripts/verify-server-json-version.ts", - "update-lighthouse": "node --experimental-strip-types scripts/update-lighthouse.ts", - "update-tool-call-metrics": "node --experimental-strip-types scripts/update_tool_call_metrics.ts", + "prepare": "node scripts/prepare.ts", + "verify-server-json-version": "node scripts/verify-server-json-version.ts", + "update-lighthouse": "node scripts/update-lighthouse.ts", + "update-tool-call-metrics": "node scripts/update_tool_call_metrics.ts", "verify-npm-package": "node scripts/verify-npm-package.mjs", - "eval": "npm run build && node --experimental-strip-types scripts/eval_gemini.ts", - "count-tokens": "node --experimental-strip-types scripts/count_tokens.ts" + "eval": "npm run build && node scripts/eval_gemini.ts", + "count-tokens": "node scripts/count_tokens.ts" }, "files": [ "build/src", @@ -62,7 +62,7 @@ "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", - "chrome-devtools-frontend": "1.0.1628368", + "chrome-devtools-frontend": "1.0.1635648", "core-js": "3.49.0", "debug": "4.4.3", "eslint": "^9.35.0", @@ -71,8 +71,8 @@ "globals": "^17.0.0", "lighthouse": "13.3.0", "prettier": "^3.6.2", - "puppeteer": "24.43.0", - "rollup": "4.60.3", + "puppeteer": "25.1.0", + "rollup": "4.60.4", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.6.0", "semver": "^7.7.4", @@ -85,5 +85,8 @@ }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "overrides": { + "puppeteer-core": "$puppeteer" } } diff --git a/release-please-config.json b/release-please-config.json index c1b08fb..c8f2926 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -45,10 +45,40 @@ "path": ".claude-plugin/plugin.json", "jsonpath": "version" }, + { + "type": "json", + "path": ".claude-plugin/plugin.json", + "jsonpath": "mcpServers['chrome-devtools'].args[0]" + }, + { + "type": "json", + "path": ".cursor-plugin/plugin.json", + "jsonpath": "version" + }, + { + "type": "json", + "path": ".cursor-plugin/plugin.json", + "jsonpath": "mcpServers['chrome-devtools'].args[0]" + }, + { + "type": "json", + "path": "gemini-extension.json", + "jsonpath": "version" + }, + { + "type": "json", + "path": "gemini-extension.json", + "jsonpath": "mcpServers['chrome-devtools'].args[0]" + }, { "type": "json", "path": ".github/plugin/plugin.json", "jsonpath": "version" + }, + { + "type": "json", + "path": ".github/plugin/plugin.json", + "jsonpath": "mcpServers['chrome-devtools'].args[0]" } ] } diff --git a/scripts/eval_gemini.ts b/scripts/eval_gemini.ts index bd5bafd..3fcd4c4 100644 --- a/scripts/eval_gemini.ts +++ b/scripts/eval_gemini.ts @@ -19,23 +19,10 @@ const ROOT_DIR = path.resolve(import.meta.dirname, '..'); const SCENARIOS_DIR = path.join(import.meta.dirname, 'eval_scenarios'); const SKILL_PATH = path.join(ROOT_DIR, 'skills', 'chrome-devtools', 'SKILL.md'); -// Define schema for our test scenarios -export interface CapturedFunctionCall { - name: string; - args: Record; -} - -export interface TestScenario { - prompt: string; - maxTurns: number; - expectations: (calls: CapturedFunctionCall[]) => void; - htmlRoute?: { - path: string; - htmlContent: string; - }; - /** Extra CLI flags passed to the MCP server (e.g. '--experimental-page-id-routing'). */ - serverArgs?: string[]; -} +import type {CapturedFunctionCall, TestScenario} from './eval_result.ts'; +import {Result} from './eval_result.ts'; +export type {CapturedFunctionCall, TestScenario}; +export {Result}; async function loadScenario(scenarioPath: string): Promise { const module = await import(pathToFileURL(scenarioPath).href); @@ -54,6 +41,7 @@ async function runSingleScenario( modelId: string, debug: boolean, includeSkill: boolean, + extraServerArgs: string[] = [], ): Promise { const debugLog = (...args: unknown[]) => { if (debug) { @@ -125,6 +113,9 @@ async function runSingleScenario( if (scenario.serverArgs) { args.push(...scenario.serverArgs); } + if (extraServerArgs.length > 0) { + args.push(...extraServerArgs); + } transport = new StdioClientTransport({ command: 'node', @@ -175,7 +166,7 @@ async function runSingleScenario( debugLog(`\n--- Response ---\n${result.text}`); debugLog('\nVerifying expectations...'); - scenario.expectations(allCalls); + scenario.expectations(new Result(allCalls, args)); } finally { try { await client?.close(); @@ -195,7 +186,7 @@ async function main() { options: { model: { type: 'string', - default: 'gemini-2.5-flash', + default: 'gemini-3-flash-preview', }, debug: { type: 'boolean', @@ -209,6 +200,9 @@ async function main() { type: 'boolean', default: false, }, + 'server-args': { + type: 'string', + }, }, allowPositionals: true, }); @@ -217,6 +211,9 @@ async function main() { const debug = values.debug; const repeat = values.repeat; const includeSkill = values['include-skill']; + const extraServerArgs = values['server-args'] + ? values['server-args'].split(/\s+/) + : []; const scenarioFiles = positionals.length > 0 @@ -248,6 +245,7 @@ async function main() { modelId, debug, includeSkill, + extraServerArgs, ); console.log(`✔ ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i})`); successCount++; diff --git a/scripts/eval_result.ts b/scripts/eval_result.ts new file mode 100644 index 0000000..9b734b2 --- /dev/null +++ b/scripts/eval_result.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +export interface CapturedFunctionCall { + name: string; + args: Record; +} + +export class Result { + private nextCallIndex = 0; + public readonly calls: CapturedFunctionCall[]; + public readonly serverArgs: string[]; + + constructor(calls: CapturedFunctionCall[], serverArgs: string[]) { + this.calls = calls; + this.serverArgs = serverArgs; + } + + get hasPageIdRouting(): boolean { + return this.serverArgs.includes('--experimental-page-id-routing'); + } + + get remainingCalls(): CapturedFunctionCall[] { + return this.calls.slice(this.nextCallIndex); + } + + /** + * Consumes initial page navigation/setup boilerplate. + * - Ignores/skips leading list_pages calls. + * - Asserts that new_page or navigate_page was called. + * - Determines the expected pageId. + * - Returns the active pageId. + */ + consumePageNavigation(): number | undefined { + if (this.calls[this.nextCallIndex]?.name === 'list_pages') { + this.nextCallIndex++; + } + + const navCall = this.calls[this.nextCallIndex]; + assert.ok( + navCall && + (navCall.name === 'new_page' || navCall.name === 'navigate_page'), + `Expected navigation call (new_page or navigate_page), but got: ${navCall?.name || 'none'}`, + ); + this.nextCallIndex++; + + const isNewPage = navCall.name === 'new_page'; + let pageId: number | undefined; + if (this.hasPageIdRouting) { + pageId = isNewPage ? 2 : 1; + } + + return pageId; + } + + /** + * Asserts that the next call in sequence has the correct name and matches expected arguments. + * Increments the internal call index. + */ + assertNextCall( + name: string, + expectedArgs?: Record, + ): CapturedFunctionCall { + const call = this.calls[this.nextCallIndex]; + assert.ok( + call, + `Expected call at index ${this.nextCallIndex} (name: '${name}') to exist`, + ); + assert.strictEqual( + call.name, + name, + `Expected call at index ${this.nextCallIndex} to be '${name}', but got '${call.name}'`, + ); + + if (expectedArgs) { + for (const entry of Object.entries(expectedArgs)) { + const key = entry[0]; + const value = entry[1]; + assert.deepStrictEqual( + call.args[key], + value, + `Expected argument '${key}' on call '${name}' to be ${JSON.stringify(value)}, got ${JSON.stringify(call.args[key])}`, + ); + } + } + + this.nextCallIndex++; + return call; + } +} + +export interface TestScenario { + prompt: string; + maxTurns: number; + expectations: (result: Result) => void; + htmlRoute?: { + path: string; + htmlContent: string; + }; + /** Extra CLI flags passed to the MCP server (e.g. '--experimental-page-id-routing'). */ + serverArgs?: string[]; +} diff --git a/scripts/eval_scenarios/console_test.ts b/scripts/eval_scenarios/console_test.ts index d82abc2..125b1e9 100644 --- a/scripts/eval_scenarios/console_test.ts +++ b/scripts/eval_scenarios/console_test.ts @@ -10,7 +10,7 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Navigate to and check the console messages.', - maxTurns: 2, + maxTurns: 3, htmlRoute: { path: '/console_test.html', htmlContent: ` @@ -20,16 +20,12 @@ export const scenario: TestScenario = { `, }, - expectations: calls => { - assert.strictEqual(calls.length, 2); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', - 'First call should be navigation', - ); - assert.strictEqual( - calls[1].name, + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.strictEqual(result.remainingCalls.length, 1); + result.assertNextCall( 'list_console_messages', - 'Second call should be list_console_messages', + result.hasPageIdRouting ? {pageId} : undefined, ); }, }; diff --git a/scripts/eval_scenarios/emulation_test.ts b/scripts/eval_scenarios/emulation_test.ts index 2eebac8..9456e45 100644 --- a/scripts/eval_scenarios/emulation_test.ts +++ b/scripts/eval_scenarios/emulation_test.ts @@ -11,9 +11,12 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Emulate offline network conditions.', maxTurns: 2, - expectations: calls => { - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].name, 'emulate'); - assert.strictEqual(calls[0].args.networkConditions, 'Offline'); + expectations: result => { + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall('list_pages'); + result.assertNextCall('emulate', { + networkConditions: 'Offline', + pageId: result.hasPageIdRouting ? 1 : undefined, + }); }, }; diff --git a/scripts/eval_scenarios/emulation_userAgent_test.ts b/scripts/eval_scenarios/emulation_userAgent_test.ts index ee0cfc7..0fb4959 100644 --- a/scripts/eval_scenarios/emulation_userAgent_test.ts +++ b/scripts/eval_scenarios/emulation_userAgent_test.ts @@ -9,14 +9,20 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - prompt: 'Emulate iPhone 14 user agent', + prompt: 'Emulate current page with iPhone 14 user agent', maxTurns: 2, - expectations: calls => { - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].name, 'emulate'); - assert.deepStrictEqual( - calls[0].args.userAgent, - 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', - ); + expectations: result => { + assert.ok(result.remainingCalls.length >= 1); + if ( + result.hasPageIdRouting || + result.remainingCalls[0]?.name === 'list_pages' + ) { + result.assertNextCall('list_pages'); + } + result.assertNextCall('emulate', { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', + pageId: result.hasPageIdRouting ? 1 : undefined, + }); }, }; diff --git a/scripts/eval_scenarios/emulation_viewport_test.ts b/scripts/eval_scenarios/emulation_viewport_test.ts index f4f5190..52a945a 100644 --- a/scripts/eval_scenarios/emulation_viewport_test.ts +++ b/scripts/eval_scenarios/emulation_viewport_test.ts @@ -9,11 +9,19 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - prompt: 'Emulate iPhone 14 viewport', + prompt: 'Emulate current page with iPhone 14 viewport', maxTurns: 2, - expectations: calls => { - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].name, 'emulate'); - assert.deepStrictEqual(calls[0].args.viewport, '390x844x3,mobile,touch'); + expectations: result => { + assert.ok(result.remainingCalls.length >= 1); + if ( + result.hasPageIdRouting || + result.remainingCalls[0]?.name === 'list_pages' + ) { + result.assertNextCall('list_pages'); + } + result.assertNextCall('emulate', { + viewport: '390x844x3,mobile,touch', + pageId: result.hasPageIdRouting ? 1 : undefined, + }); }, }; diff --git a/scripts/eval_scenarios/fill_select_and_checkboxes_test.ts b/scripts/eval_scenarios/fill_select_and_checkboxes_test.ts index 9876e64..cb66cbb 100644 --- a/scripts/eval_scenarios/fill_select_and_checkboxes_test.ts +++ b/scripts/eval_scenarios/fill_select_and_checkboxes_test.ts @@ -11,7 +11,7 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Go to , fill the form with size = 2 CPUs and components = [docker, nginx].', - maxTurns: 4, // allow for at least one extra turn to verify there are no extra clicks after fill_form + maxTurns: 5, // allow for at least one extra turn to verify there are no extra clicks after fill_form htmlRoute: { path: '/input_test.html', htmlContent: ` @@ -40,32 +40,42 @@ export const scenario: TestScenario = { `, }, - expectations: calls => { - assert.ok(calls.length >= 3, 'Not enough calls made'); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 2, 'Not enough calls made'); + result.assertNextCall( + 'take_snapshot', + result.hasPageIdRouting ? {pageId} : undefined, + ); + const fillFormCall = result.assertNextCall( + 'fill_form', + result.hasPageIdRouting ? {pageId} : undefined, ); - assert.strictEqual(calls[1].name, 'take_snapshot'); - assert.strictEqual(calls[2].name, 'fill_form'); - const elements = calls[2].args.elements as Array<{ - uid: string; - value: string; - }>; + const elements = fillFormCall.args.elements; + assert.ok(Array.isArray(elements), 'elements should be an array'); assert.strictEqual( elements.length, 3, 'fill_form should be used with all form elements at once', ); - const uids = new Set(elements.map(e => e.uid)); + const typedElements = elements.map(e => { + assert.ok(e && typeof e === 'object' && 'uid' in e && 'value' in e); + return { + uid: String(e.uid), + value: String(e.value), + }; + }); + + const uids = new Set(typedElements.map(e => e.uid)); assert.strictEqual( uids.size, 3, 'fill_form should target three distinct elements', ); - const values = elements.map(e => e.value).sort(); + const values = typedElements.map(e => e.value).sort(); assert.deepStrictEqual( values, ['2 vCPU, 4GB RAM', 'true', 'true'], @@ -74,11 +84,9 @@ export const scenario: TestScenario = { const submitUid = '1_15'; - const extraFormInteractions = calls - .slice(3) - .filter( - c => ['fill', 'click'].includes(c.name) && c.args.uid !== submitUid, - ); + const extraFormInteractions = result.remainingCalls.filter( + c => ['fill', 'click'].includes(c.name) && c.args.uid !== submitUid, + ); assert.deepEqual( extraFormInteractions.length, 0, diff --git a/scripts/eval_scenarios/fix_webpage_issues_test.ts b/scripts/eval_scenarios/fix_webpage_issues_test.ts index df19af9..444def9 100644 --- a/scripts/eval_scenarios/fix_webpage_issues_test.ts +++ b/scripts/eval_scenarios/fix_webpage_issues_test.ts @@ -25,7 +25,7 @@ const INSPECTION_TOOLS = [ ]; export const scenario: TestScenario = { - prompt: 'Can you fix issues with my webpage?', + prompt: 'Can you fix issues with my webpage at ?', maxTurns: 4, htmlRoute: { path: '/fix_issues_test.html', @@ -37,26 +37,23 @@ export const scenario: TestScenario = { `, }, - expectations: calls => { - const NAVIGATION_TOOLS = ['navigate_page', 'new_page']; - assert.ok( - calls.length >= 2, - 'Expected at least navigation and one inspection', - ); - const navigationIndex = calls.findIndex(c => - NAVIGATION_TOOLS.includes(c.name), - ); - assert.ok( - navigationIndex !== -1, - `Expected a navigation call (${NAVIGATION_TOOLS.join(' or ')}), got: ${calls.map(c => c.name).join(', ')}`, - ); - const afterNavigation = calls.slice(navigationIndex + 1); - const inspectionCalls = afterNavigation.filter(c => + expectations: result => { + const pageId = result.consumePageNavigation(); + const inspectionCalls = result.remainingCalls.filter(c => INSPECTION_TOOLS.includes(c.name), ); assert.ok( inspectionCalls.length >= 1, - `Expected at least one inspection tool (${INSPECTION_TOOLS.join(', ')}) after navigation, got: ${calls.map(c => c.name).join(', ')}`, + `Expected at least one inspection tool (${INSPECTION_TOOLS.join(', ')}) after navigation, got: ${result.remainingCalls.map(c => c.name).join(', ')}`, ); + if (result.hasPageIdRouting) { + for (const inspectionCall of inspectionCalls) { + assert.strictEqual( + inspectionCall.args.pageId, + pageId, + `Inspection call ${inspectionCall.name} should target pageId: ${pageId}`, + ); + } + } }, }; diff --git a/scripts/eval_scenarios/frontend_snapshot_test.ts b/scripts/eval_scenarios/frontend_snapshot_test.ts index c19b001..c2166fe 100644 --- a/scripts/eval_scenarios/frontend_snapshot_test.ts +++ b/scripts/eval_scenarios/frontend_snapshot_test.ts @@ -19,16 +19,12 @@ export const scenario: TestScenario = { path: '/frontend_snapshot.html', htmlContent: '

Frontend Test

This is a test webpage.

', }, - expectations: calls => { - assert.strictEqual(calls.length, 2); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', - 'First call should be navigation', - ); - assert.strictEqual( - calls[1].name, + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall( 'take_snapshot', - 'Second call should be take_snapshot to read page content', + result.hasPageIdRouting ? {pageId} : undefined, ); }, }; diff --git a/scripts/eval_scenarios/input_parallel_test.ts b/scripts/eval_scenarios/input_parallel_test.ts index e931480..7c466b8 100644 --- a/scripts/eval_scenarios/input_parallel_test.ts +++ b/scripts/eval_scenarios/input_parallel_test.ts @@ -10,7 +10,7 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: - 'Go to , fill the input with "hello world" and click the button five times in parallel.', + 'Go to , fill the input with "hello world" and click the button five times in parallel without using a script.', maxTurns: 10, htmlRoute: { path: '/input_test.html', @@ -19,16 +19,22 @@ export const scenario: TestScenario = { `, }, - expectations: calls => { - assert.strictEqual(calls.length, 8); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 7); + result.assertNextCall( + 'take_snapshot', + result.hasPageIdRouting ? {pageId} : undefined, ); - assert.ok(calls[1].name === 'take_snapshot'); - assert.ok(calls[2].name === 'fill'); - for (let i = 3; i < 8; i++) { - assert.ok(calls[i].name === 'click'); - assert.strictEqual(Boolean(calls[i].args.includeSnapshot), false); + result.assertNextCall( + 'fill', + result.hasPageIdRouting ? {pageId} : undefined, + ); + for (let i = 2; i < 7; i++) { + result.assertNextCall('click', { + includeSnapshot: undefined, + pageId: result.hasPageIdRouting ? pageId : undefined, + }); } }, }; diff --git a/scripts/eval_scenarios/input_test.ts b/scripts/eval_scenarios/input_test.ts index 6078e7f..e7fef7a 100644 --- a/scripts/eval_scenarios/input_test.ts +++ b/scripts/eval_scenarios/input_test.ts @@ -11,7 +11,7 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Go to , fill the input with "hello world" and click the button.', - maxTurns: 4, + maxTurns: 5, htmlRoute: { path: '/input_test.html', htmlContent: ` @@ -19,13 +19,20 @@ export const scenario: TestScenario = { `, }, - expectations: calls => { - assert.strictEqual(calls.length, 4); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 3); + result.assertNextCall( + 'take_snapshot', + result.hasPageIdRouting ? {pageId} : undefined, + ); + result.assertNextCall( + 'fill', + result.hasPageIdRouting ? {pageId} : undefined, + ); + result.assertNextCall( + 'click', + result.hasPageIdRouting ? {pageId} : undefined, ); - assert.ok(calls[1].name === 'take_snapshot'); - assert.ok(calls[2].name === 'fill'); - assert.ok(calls[3].name === 'click'); }, }; diff --git a/scripts/eval_scenarios/isolated_context_test.ts b/scripts/eval_scenarios/isolated_context_test.ts index 0d76e5f..c1ae69a 100644 --- a/scripts/eval_scenarios/isolated_context_test.ts +++ b/scripts/eval_scenarios/isolated_context_test.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import assert from 'node:assert'; - import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { @@ -13,19 +11,14 @@ export const scenario: TestScenario = { 'Create a new page in an isolated context called contextB. Take a screenshot there.', maxTurns: 3, htmlRoute: { - path: '/test.html', - htmlContent: ` -

test

- `, + path: '/isolated_context.html', + htmlContent: '

Isolated Context

', }, - expectations: calls => { - console.log(JSON.stringify(calls, null, 2)); - assert.strictEqual(calls.length, 2); - assert.ok(calls[0].name === 'new_page', 'First call should be navigation'); - assert.deepStrictEqual(calls[0].args.isolatedContext, 'contextB'); - assert.ok( - calls[1].name === 'take_screenshot', - 'Second call should be a screenshot', + expectations: result => { + result.assertNextCall('new_page', {isolatedContext: 'contextB'}); + result.assertNextCall( + 'take_screenshot', + result.hasPageIdRouting ? {pageId: 2} : undefined, ); }, }; diff --git a/scripts/eval_scenarios/lighthouse_a11y_test.ts b/scripts/eval_scenarios/lighthouse_a11y_test.ts index cf0a0e6..457192f 100644 --- a/scripts/eval_scenarios/lighthouse_a11y_test.ts +++ b/scripts/eval_scenarios/lighthouse_a11y_test.ts @@ -9,13 +9,29 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - prompt: 'Check a11y issues on the current page', - maxTurns: 1, - expectations: calls => { - assert.strictEqual(calls.length, 1); - assert.ok( - calls[0].name === 'lighthouse_audit', - 'First call should be lighthouse_audit', + prompt: 'Check a11y issues on the page at ', + maxTurns: 3, + htmlRoute: { + path: '/lighthouse_test.html', + htmlContent: ` + + + + Lighthouse Audit Test + + +

Lighthouse Audit Test

+

This is a valid test page for running audits.

+ + + `, + }, + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall( + 'lighthouse_audit', + result.hasPageIdRouting ? {pageId} : undefined, ); }, }; diff --git a/scripts/eval_scenarios/lighthouse_best_practices_test.ts b/scripts/eval_scenarios/lighthouse_best_practices_test.ts index 54aed3b..40d4dd1 100644 --- a/scripts/eval_scenarios/lighthouse_best_practices_test.ts +++ b/scripts/eval_scenarios/lighthouse_best_practices_test.ts @@ -9,13 +9,29 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - prompt: 'Check for best practices on the current page', - maxTurns: 1, - expectations: calls => { - assert.strictEqual(calls.length, 1); - assert.ok( - calls[0].name === 'lighthouse_audit', - 'First call should be lighthouse_audit', + prompt: 'Check for best practices on the page at ', + maxTurns: 3, + htmlRoute: { + path: '/lighthouse_test.html', + htmlContent: ` + + + + Lighthouse Audit Test + + +

Lighthouse Audit Test

+

This is a valid test page for running audits.

+ + + `, + }, + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall( + 'lighthouse_audit', + result.hasPageIdRouting ? {pageId} : undefined, ); }, }; diff --git a/scripts/eval_scenarios/navigation_test.ts b/scripts/eval_scenarios/navigation_test.ts index 7619b68..6d214e5 100644 --- a/scripts/eval_scenarios/navigation_test.ts +++ b/scripts/eval_scenarios/navigation_test.ts @@ -9,14 +9,17 @@ import assert from 'node:assert'; import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { - prompt: 'Navigate to https://developers.chrome.com and tell me if it worked.', - maxTurns: 1, - expectations: calls => { - assert.deepStrictEqual(calls, [ - { - name: 'navigate_page', - args: {url: 'https://developers.chrome.com'}, - }, - ]); + prompt: + 'Navigate in current page to https://developers.chrome.com and tell me if it worked.', + maxTurns: 2, + expectations: result => { + if (result.hasPageIdRouting) { + result.assertNextCall('list_pages'); + } + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall('navigate_page', { + url: 'https://developers.chrome.com', + pageId: result.hasPageIdRouting ? 1 : undefined, + }); }, }; diff --git a/scripts/eval_scenarios/network_test.ts b/scripts/eval_scenarios/network_test.ts index bacb26a..83e6ed0 100644 --- a/scripts/eval_scenarios/network_test.ts +++ b/scripts/eval_scenarios/network_test.ts @@ -10,7 +10,7 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Navigate to and list all network requests.', - maxTurns: 2, + maxTurns: 3, htmlRoute: { path: '/network_test.html', htmlContent: ` @@ -20,16 +20,12 @@ export const scenario: TestScenario = { `, }, - expectations: calls => { - assert.strictEqual(calls.length, 2); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', - 'First call should be navigation', - ); - assert.strictEqual( - calls[1].name, + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall( 'list_network_requests', - 'Second call should be list_network_requests', + result.hasPageIdRouting ? {pageId} : undefined, ); }, }; diff --git a/scripts/eval_scenarios/page_focus_keyboard_test.ts b/scripts/eval_scenarios/page_focus_keyboard_test.ts index 655b1fe..f56c7c6 100644 --- a/scripts/eval_scenarios/page_focus_keyboard_test.ts +++ b/scripts/eval_scenarios/page_focus_keyboard_test.ts @@ -16,22 +16,29 @@ export const scenario: TestScenario = { Now use the press_key tool to type "a" on Page 1 without selecting it first. You must use press_key, not fill or type_text. If you encounter any errors, recover from them.`, maxTurns: 10, - expectations: calls => { + expectations: result => { // Should open 2 pages in the same context. - const newPages = calls.filter(c => c.name === 'new_page'); + const newPages = result.calls.filter(c => c.name === 'new_page'); assert.strictEqual(newPages.length, 2, 'Should open 2 pages'); assert.strictEqual(newPages[0].args.isolatedContext, 'session'); assert.strictEqual(newPages[1].args.isolatedContext, 'session'); // Should attempt press_key at least once. - const pressKeys = calls.filter(c => c.name === 'press_key'); + const pressKeys = result.calls.filter(c => c.name === 'press_key'); assert.ok(pressKeys.length >= 1, 'Should attempt press_key at least once'); + for (const pk of pressKeys) { + assert.strictEqual( + pk.args.pageId, + 2, + 'press_key should target Page 1 (pageId: 2)', + ); + } - const selectPages = calls.filter(c => c.name === 'select_page'); + const selectPages = result.calls.filter(c => c.name === 'select_page'); if (selectPages.length > 0) { - const firstPressKeyIndex = calls.indexOf(pressKeys[0]); - const firstSelectPageIndex = calls.indexOf(selectPages[0]); + const firstPressKeyIndex = result.calls.indexOf(pressKeys[0]); + const firstSelectPageIndex = result.calls.indexOf(selectPages[0]); if (firstPressKeyIndex < firstSelectPageIndex) { // Error path: press_key was attempted first and failed. @@ -40,7 +47,7 @@ Now use the press_key tool to type "a" on Page 1 without selecting it first. You pressKeys.length >= 2, 'Should retry press_key after error recovery', ); - const lastPressKeyIndex = calls.lastIndexOf(pressKeys.at(-1)!); + const lastPressKeyIndex = result.calls.lastIndexOf(pressKeys.at(-1)!); assert.ok( firstSelectPageIndex < lastPressKeyIndex, 'select_page should precede the successful press_key', diff --git a/scripts/eval_scenarios/page_id_routing_test.ts b/scripts/eval_scenarios/page_id_routing_test.ts index 39007a4..a65c9e8 100644 --- a/scripts/eval_scenarios/page_id_routing_test.ts +++ b/scripts/eval_scenarios/page_id_routing_test.ts @@ -15,9 +15,9 @@ export const scenario: TestScenario = { - Page B (isolatedContext "contextB") at data:text/html, Then take a snapshot of Page A, take a snapshot of Page B, and then click the button on Page A.`, maxTurns: 12, - expectations: calls => { + expectations: result => { // Should have 2 new_page calls with isolatedContext. - const newPages = calls.filter(c => c.name === 'new_page'); + const newPages = result.calls.filter(c => c.name === 'new_page'); assert.strictEqual(newPages.length, 2, 'Should open 2 pages'); for (const np of newPages) { assert.strictEqual( @@ -29,12 +29,26 @@ Then take a snapshot of Page A, take a snapshot of Page B, and then click the bu // Should have at least 2 take_snapshot calls (one per page). // The model may use pageId directly or select_page before each snapshot. - const snapshots = calls.filter(c => c.name === 'take_snapshot'); + const snapshots = result.calls.filter(c => c.name === 'take_snapshot'); assert.ok(snapshots.length >= 2, 'Should take at least 2 snapshots'); + const snapshotPageIds = snapshots.map(s => s.args.pageId); + assert.ok( + snapshotPageIds.includes(2), + 'Should snapshot Page A (pageId: 2)', + ); + assert.ok( + snapshotPageIds.includes(3), + 'Should snapshot Page B (pageId: 3)', + ); // Should have a click call (resolving uid from Page A's snapshot // even though Page B was snapshotted after). - const clicks = calls.filter(c => c.name === 'click'); + const clicks = result.calls.filter(c => c.name === 'click'); assert.ok(clicks.length >= 1, 'Should click the button on Page A'); + assert.strictEqual( + clicks[0].args.pageId, + 2, + 'Click should target Page A (pageId: 2)', + ); }, }; diff --git a/scripts/eval_scenarios/performance_test.ts b/scripts/eval_scenarios/performance_test.ts index 63ab322..6652e75 100644 --- a/scripts/eval_scenarios/performance_test.ts +++ b/scripts/eval_scenarios/performance_test.ts @@ -10,12 +10,13 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Check the performance of https://developers.chrome.com', - maxTurns: 2, - expectations: calls => { - assert.strictEqual(calls.length, 2); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', + maxTurns: 3, + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.ok(result.remainingCalls.length >= 1); + result.assertNextCall( + 'performance_start_trace', + result.hasPageIdRouting ? {pageId} : undefined, ); - assert.ok(calls[1].name === 'performance_start_trace'); }, }; diff --git a/scripts/eval_scenarios/select_page_test.ts b/scripts/eval_scenarios/select_page_test.ts index a8bfba3..65d8355 100644 --- a/scripts/eval_scenarios/select_page_test.ts +++ b/scripts/eval_scenarios/select_page_test.ts @@ -18,23 +18,10 @@ export const scenario: TestScenario = {

test

`, }, - expectations: calls => { - assert.strictEqual(calls.length, 3); - assert.ok(calls[0].name === 'new_page', 'First call should be navigation'); - assert.ok(calls[1].name === 'new_page', 'Second call should be navigation'); - assert.ok( - calls[2].name === 'select_page', - 'Third call should be select_page', - ); - assert.strictEqual( - calls[2].args.pageId, - 2, - 'PageId has to be set to 2. about:blank is 1, is 2, https://developers.chrome.com is 3.', - ); - assert.strictEqual( - calls[2].args.bringToFront, - undefined, - 'bringToFront should use the default value.', - ); + expectations: result => { + result.consumePageNavigation(); + assert.strictEqual(result.remainingCalls.length, 2); + result.assertNextCall('new_page'); + result.assertNextCall('select_page', {pageId: 2, bringToFront: undefined}); }, }; diff --git a/scripts/eval_scenarios/snapshot_test.ts b/scripts/eval_scenarios/snapshot_test.ts index 4877c1d..027e808 100644 --- a/scripts/eval_scenarios/snapshot_test.ts +++ b/scripts/eval_scenarios/snapshot_test.ts @@ -10,16 +10,17 @@ import type {TestScenario} from '../eval_gemini.ts'; export const scenario: TestScenario = { prompt: 'Read the content of ', - maxTurns: 3, + maxTurns: 4, htmlRoute: { path: '/test.html', htmlContent: '

Hello World

This is a test.

', }, - expectations: calls => { - assert.strictEqual(calls.length, 2); - assert.ok( - calls[0].name === 'navigate_page' || calls[0].name === 'new_page', + expectations: result => { + const pageId = result.consumePageNavigation(); + assert.strictEqual(result.remainingCalls.length, 1); + result.assertNextCall( + 'take_snapshot', + result.hasPageIdRouting ? {pageId} : undefined, ); - assert.ok(calls[1].name === 'take_snapshot'); }, }; diff --git a/scripts/test.mjs b/scripts/test.mjs index 19e6234..eba673b 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -38,11 +38,7 @@ if (userArgs.length > 0) { files.push(testPath); } } else { - const isNode20 = process.version.startsWith('v20.'); if (flags.includes('--test-only')) { - if (isNode20) { - throw new Error(`--test-only is not supported for Node 20`); - } const {glob} = await import('node:fs/promises'); for await (const tsFile of glob('tests/**/*.test.ts')) { const content = await readFile(tsFile, 'utf8'); @@ -55,11 +51,7 @@ if (userArgs.length > 0) { process.exit(0); } } else if (files.length === 0) { - if (isNode20) { - files.push('build/tests'); - } else { - files.push('build/tests/**/*.test.js'); - } + files.push('build/tests/**/*.test.js'); } } diff --git a/scripts/update_metrics.ts b/scripts/update_metrics.ts index 6408782..f6bb5ef 100644 --- a/scripts/update_metrics.ts +++ b/scripts/update_metrics.ts @@ -11,6 +11,7 @@ import { cliOptions, parseArguments, } from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; +import {ErrorCode} from '../build/src/telemetry/errors.js'; import { getPossibleFlagMetrics, type FlagMetric, @@ -20,7 +21,7 @@ import { applyToExistingMetrics, generateToolMetrics, type ToolMetric, -} from '../build/src/telemetry/toolMetricsUtils.js'; +} from '../build/src/telemetry/metricsRegistry.js'; import {createTools} from '../build/src/tools/tools.js'; export function HaveUniqueNames(tools: Array<{name: string}>): boolean { @@ -104,7 +105,34 @@ function writeFlagUsageMetrics() { ); } +function validateErrorCodes(): void { + // The compiled JavaScript object has both forward and backward mappings of the enum, + // for example: { '0': 'ERROR_CODE_UNSPECIFIED', 'ERROR_CODE_UNSPECIFIED': 0 }. + // This filters out the numeric keys. + const stringKeysOnly = Object.entries(ErrorCode).filter(([key]) => + isNaN(Number(key)), + ); + + let expectedIndex = 0; + for (const [key, index] of stringKeysOnly) { + if (index !== expectedIndex) { + throw new Error( + `Error: ErrorCode enums must be sequentially numbered from 0.`, + ); + } + if (/_\d/.test(key)) { + throw new Error( + `Error: ErrorCode enum ${key} is invalid. No numbers should be preceded with an underscore.`, + ); + } + expectedIndex++; + } + + console.log(`Successfully validated ${expectedIndex} ErrorCode enums.`); +} + function main() { + validateErrorCodes(); writeToolCallMetricsConfig(); writeFlagUsageMetrics(); } diff --git a/scripts/update_tool_call_metrics.ts b/scripts/update_tool_call_metrics.ts index 0e31215..0a2438b 100644 --- a/scripts/update_tool_call_metrics.ts +++ b/scripts/update_tool_call_metrics.ts @@ -14,7 +14,7 @@ import { applyToExistingMetrics, generateToolMetrics, type ToolMetric, -} from '../build/src/telemetry/toolMetricsUtils.js'; +} from '../build/src/telemetry/metricsRegistry.js'; import type {ToolDefinition} from '../build/src/tools/ToolDefinition.js'; import {createTools} from '../build/src/tools/tools.js'; diff --git a/scripts/verify-npm-package.mjs b/scripts/verify-npm-package.mjs index 44c7466..600b3af 100644 --- a/scripts/verify-npm-package.mjs +++ b/scripts/verify-npm-package.mjs @@ -14,7 +14,7 @@ function verifyPackageContents() { }); // skip non-JSON output from prepare. const data = JSON.parse(output.substring(output.indexOf('{'))); - const files = data.files.map(f => f.path); + const files = data['chrome-devtools-mcp'].files.map(f => f.path); // Check some important files. const requiredPaths = [ 'build/src/index.js', diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 7a4abe9..a0e043e 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -9,6 +9,7 @@ import {Mutex} from './Mutex.js'; import {DevTools} from './third_party/index.js'; import type { Browser, + CDPSession, ConsoleMessage, Page, Protocol, @@ -49,6 +50,8 @@ export interface TargetUniverse { /** The DevTools target corresponding to the puppeteer Page */ target: DevTools.Target; universe: DevTools.Foundation.Universe.Universe; + /** The secondary session created for this page */ + session: CDPSession; } export type TargetUniverseFactoryFn = (page: Page) => Promise; @@ -144,6 +147,10 @@ const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => { const targetManager = universe.context.get(DevTools.TargetManager); targetManager.observeModels(DevTools.DebuggerModel, SKIP_ALL_PAUSES); + targetManager.observeModels( + DevTools.NetworkManager.NetworkManager, + DISABLE_NETWORK, + ); const target = targetManager.createTarget( 'main', @@ -154,7 +161,7 @@ const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => { undefined, connection, ); - return {target, universe}; + return {target, universe, session}; }; // We don't want to pause any DevTools universe session ever on the MCP side. @@ -172,6 +179,20 @@ const SKIP_ALL_PAUSES = { }, }; +// Not recording network requests in the DevTools universe. +// +// The network requests are collected through pptr and there isn't a use case for +// enabling devtools SDK's network domain. +const DISABLE_NETWORK = { + modelAdded(model: DevTools.NetworkManager.NetworkManager): void { + void model.target().networkAgent().invoke_disable(); + }, + + modelRemoved(): void { + // Do nothing. + }, +}; + /** * Constructed from Runtime.ExceptionDetails of an uncaught error. * diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 81d875e..c84b04f 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -7,6 +7,7 @@ import fsSync from 'node:fs'; import path from 'node:path'; +import {isNodeLike} from './formatters/HeapSnapshotFormatter.js'; import {DevTools} from './third_party/index.js'; import { createIdGenerator, @@ -14,7 +15,7 @@ import { type WithSymbolId, } from './utils/id.js'; -export type AggregatedInfoWithUid = +export type AggregatedInfoWithId = WithSymbolId; export class HeapSnapshotManager { @@ -24,8 +25,8 @@ export class HeapSnapshotManager { snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy; worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy; // TODO: use a multimap - uidToClassKey: Map; - classKeyToUid: Map; + idToClassKey: Map; + classKeyToId: Map; idGenerator: () => number; } >(); @@ -43,8 +44,8 @@ export class HeapSnapshotManager { this.#snapshots.set(absolutePath, { snapshot, worker, - uidToClassKey: new Map(), - classKeyToUid: new Map(), + idToClassKey: new Map(), + classKeyToId: new Map(), idGenerator: createIdGenerator(), }); @@ -53,18 +54,18 @@ export class HeapSnapshotManager { async getAggregates( filePath: string, - ): Promise> { + ): Promise> { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); - const aggregates: Record = + const aggregates: Record = await snapshot.aggregatesWithFilter(filter); for (const key of Object.keys(aggregates)) { - const uid = await this.getOrCreateUidForClassKey(filePath, key); + const id = await this.getOrCreateIdForClassKey(filePath, key); const aggregate = aggregates[key]; if (aggregate) { - aggregate[stableIdSymbol] = uid; + aggregate[stableIdSymbol] = id; } } @@ -85,35 +86,68 @@ export class HeapSnapshotManager { return snapshot.staticData; } - async getOrCreateUidForClassKey( + async getOrCreateIdForClassKey( filePath: string, classKey: string, ): Promise { const cached = this.#getCachedSnapshot(filePath); - let uid = cached.classKeyToUid.get(classKey); - if (!uid) { - uid = cached.idGenerator(); - cached.classKeyToUid.set(classKey, uid); - cached.uidToClassKey.set(uid, classKey); + let id = cached.classKeyToId.get(classKey); + if (!id) { + id = cached.idGenerator(); + cached.classKeyToId.set(classKey, id); + cached.idToClassKey.set(id, classKey); } - return uid; + return id; } - async getNodesByUid( + async getNodesById( filePath: string, - uid: number, + id: number, ): Promise { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); - const className = await this.resolveClassKeyFromUid(filePath, uid); + const className = await this.resolveClassKeyFromId(filePath, id); if (!className) { - throw new Error(`Class with UID ${uid} not found in heap snapshot`); + throw new Error(`Class with ID ${id} not found in heap snapshot`); } const provider = snapshot.createNodesProviderForClass(className, filter); - const range = await provider.serializeItemsRange(0, 1); - return await provider.serializeItemsRange(0, range.totalLength); + return await provider.serializeItemsRange(0, Infinity); + } + + async findNodeIndexById( + filePath: string, + nodeId: number, + ): Promise { + const snapshot = await this.getSnapshot(filePath); + const aggregates = await this.getAggregates(filePath); + const filter = + new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); + + for (const classKey of Object.keys(aggregates)) { + const provider = snapshot.createNodesProviderForClass(classKey, filter); + const range = await provider.serializeItemsRange(0, Infinity); + for (const item of range.items) { + if (isNodeLike(item) && item.id === nodeId) { + return item.nodeIndex; + } + } + } + return undefined; + } + + async getRetainers( + filePath: string, + nodeId: number, + ): Promise { + const nodeIndex = await this.findNodeIndexById(filePath, nodeId); + if (nodeIndex === undefined) { + throw new Error(`Node with ID ${nodeId} not found`); + } + const snapshot = await this.getSnapshot(filePath); + const provider = snapshot.createRetainingEdgesProvider(nodeIndex); + return await provider.serializeItemsRange(0, Infinity); } #getCachedSnapshot(filePath: string) { @@ -125,12 +159,12 @@ export class HeapSnapshotManager { return cached; } - async resolveClassKeyFromUid( + async resolveClassKeyFromId( filePath: string, - uid: number, + id: number, ): Promise { const cached = this.#getCachedSnapshot(filePath); - return cached.uidToClassKey.get(uid); + return cached.idToClassKey.get(id); } async #loadSnapshot(absolutePath: string): Promise<{ diff --git a/src/McpContext.ts b/src/McpContext.ts index 0b81076..3855c02 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -5,6 +5,7 @@ */ import fs from 'node:fs/promises'; +import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; @@ -12,7 +13,7 @@ import {fileURLToPath, pathToFileURL} from 'node:url'; import type {TargetUniverse} from './DevtoolsUtils.js'; import {UniverseManager} from './DevtoolsUtils.js'; import {HeapSnapshotManager} from './HeapSnapshotManager.js'; -import type {AggregatedInfoWithUid} from './HeapSnapshotManager.js'; +import type {AggregatedInfoWithId} from './HeapSnapshotManager.js'; import {McpPage} from './McpPage.js'; import { NetworkCollector, @@ -45,7 +46,11 @@ import type { GeolocationOptions, ExtensionServiceWorker, } from './types.js'; -import {ensureExtension, getTempFilePath} from './utils/files.js'; +import { + ensureExtension, + getTempFilePath, + resolveCanonicalPath, +} from './utils/files.js'; import {getNetworkMultiplierFromString} from './WaitForHelper.js'; interface McpContextOptions { @@ -175,7 +180,7 @@ export class McpContext implements Context { this.#roots = roots; } - validatePath(filePath?: string): void { + async validatePath(filePath?: string): Promise { if (filePath === undefined) { return; } @@ -183,19 +188,50 @@ export class McpContext implements Context { if (roots === undefined) { return; } - const absolutePath = path.resolve(filePath); + + let canonicalPath: string; + + try { + canonicalPath = await resolveCanonicalPath(filePath); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + console.error( + `[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`, + ); + throw new Error( + `Access denied: Cannot resolve base path for ${filePath}.`, + ); + } + + let allowed = false; for (const root of roots) { - const rootPath = path.resolve(fileURLToPath(root.uri)); - if ( - absolutePath === rootPath || - absolutePath.startsWith(rootPath + path.sep) - ) { - return; + try { + const rootPathUri = root.uri; + const rootPath = path.resolve(fileURLToPath(rootPathUri)); + const canonicalRoot = await fsPromises.realpath(rootPath); + + if ( + canonicalPath === canonicalRoot || + canonicalPath.startsWith(canonicalRoot + path.sep) + ) { + allowed = true; + break; + } + } catch (rootErr) { + const errMsg = + rootErr instanceof Error ? rootErr.message : String(rootErr); + console.warn( + `[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`, + ); + // Skip this root if it cannot be resolved. } } - throw new Error( - `Access denied: path ${filePath} is not within any of the workspace roots ${JSON.stringify(roots)}.`, - ); + + if (!allowed) { + throw new Error( + `Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`, + ); + } } resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined { @@ -301,6 +337,7 @@ export class McpContext implements Context { userAgent?: string; colorScheme?: 'dark' | 'light' | 'auto'; viewport?: Viewport; + extraHttpHeaders?: Record | undefined; }, targetPage?: Page, ): Promise { @@ -328,11 +365,22 @@ export class McpContext implements Context { newSettings.networkConditions = options.networkConditions; } + const secondarySession = this.getDevToolsUniverse(mcpPage)?.session; if (!options.cpuThrottlingRate) { await page.emulateCPUThrottling(1); + if (secondarySession) { + await secondarySession.send('Emulation.setCPUThrottlingRate', { + rate: 1, + }); + } delete newSettings.cpuThrottlingRate; } else { await page.emulateCPUThrottling(options.cpuThrottlingRate); + if (secondarySession) { + await secondarySession.send('Emulation.setCPUThrottlingRate', { + rate: options.cpuThrottlingRate, + }); + } newSettings.cpuThrottlingRate = options.cpuThrottlingRate; } @@ -365,7 +413,6 @@ export class McpContext implements Context { } if (!options.viewport) { - await page.setViewport(null); delete newSettings.viewport; } else { const defaults = { @@ -374,9 +421,15 @@ export class McpContext implements Context { hasTouch: false, isLandscape: false, }; - const viewport = {...defaults, ...options.viewport}; - await page.setViewport(viewport); - newSettings.viewport = viewport; + newSettings.viewport = {...defaults, ...options.viewport}; + } + + if (options.extraHttpHeaders !== undefined) { + await page.setExtraHTTPHeaders(options.extraHttpHeaders); + newSettings.extraHttpHeaders = options.extraHttpHeaders; + if (Object.keys(options.extraHttpHeaders).length === 0) { + delete newSettings.extraHttpHeaders; + } } mcpPage.emulationSettings = Object.keys(newSettings).length @@ -384,6 +437,10 @@ export class McpContext implements Context { : {}; this.#updateSelectedPageTimeouts(); + + // This should happen after updating the page timeouts. + // Setting the viewport can trigger a reload which we don't want to timeout. + await page.setViewport(newSettings.viewport ?? null); } setIsRunningPerformanceTrace(x: boolean): void { @@ -467,12 +524,12 @@ export class McpContext implements Context { page.pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier); // 10sec should be enough for the load event to be emitted during // navigations. - // Increased in case we throttle the network requests + // Increased in case we throttle the network requests or the CPU const networkMultiplier = getNetworkMultiplierFromString( page.networkConditions, ); page.pptrPage.setDefaultNavigationTimeout( - NAVIGATION_TIMEOUT * networkMultiplier, + NAVIGATION_TIMEOUT * networkMultiplier * cpuMultiplier, ); } @@ -688,7 +745,7 @@ export class McpContext implements Context { filename: string, ): Promise<{filepath: string}> { const filepath = await getTempFilePath(filename); - this.validatePath(filepath); + await this.validatePath(filepath); try { await fs.writeFile(filepath, data); } catch (err) { @@ -702,7 +759,7 @@ export class McpContext implements Context { clientProvidedFilePath: string, extension: SupportedExtensions, ): Promise<{filename: string}> { - this.validatePath(clientProvidedFilePath); + await this.validatePath(clientProvidedFilePath); try { const filePath = ensureExtension( path.resolve(clientProvidedFilePath), @@ -774,7 +831,6 @@ export class McpContext implements Context { } async installExtension(extensionPath: string): Promise { - this.validatePath(extensionPath); const id = await this.browser.installExtension(extensionPath); return id; } @@ -804,30 +860,33 @@ export class McpContext implements Context { async getHeapSnapshotAggregates( filePath: string, - ): Promise> { - this.validatePath(filePath); + ): Promise> { return await this.#heapSnapshotManager.getAggregates(filePath); } async getHeapSnapshotStats( filePath: string, ): Promise { - this.validatePath(filePath); return await this.#heapSnapshotManager.getStats(filePath); } async getHeapSnapshotStaticData( filePath: string, ): Promise { - this.validatePath(filePath); return await this.#heapSnapshotManager.getStaticData(filePath); } - async getHeapSnapshotNodesByUid( + async getHeapSnapshotNodesById( + filePath: string, + id: number, + ): Promise { + return await this.#heapSnapshotManager.getNodesById(filePath, id); + } + + async getHeapSnapshotRetainers( filePath: string, - uid: number, + nodeId: number, ): Promise { - this.validatePath(filePath); - return await this.#heapSnapshotManager.getNodesByUid(filePath, uid); + return await this.#heapSnapshotManager.getRetainers(filePath, nodeId); } } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 414f2e6..d625069 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -11,7 +11,7 @@ import type {WebMCPTool} from 'puppeteer-core'; import type {ParsedArguments} from './bin/opera-devtools-mcp-cli-options.js'; import {ConsoleFormatter} from './formatters/ConsoleFormatter.js'; import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js'; -import {isNodeLike} from './formatters/HeapSnapshotFormatter.js'; +import {isEdgeLike, isNodeLike} from './formatters/HeapSnapshotFormatter.js'; import {IssueFormatter} from './formatters/IssueFormatter.js'; import {NetworkFormatter} from './formatters/NetworkFormatter.js'; import {SnapshotFormatter} from './formatters/SnapshotFormatter.js'; @@ -42,6 +42,7 @@ import type {InsightName, TraceResult} from './trace-processing/parse.js'; import {getInsightOutput, getTraceSummary} from './trace-processing/parse.js'; import {paginate} from './utils/pagination.js'; import type {PaginationOptions} from './utils/types.js'; +import type {WaitForEventsResult} from './WaitForHelper.js'; interface TraceInsightData { trace: TraceResult; @@ -208,8 +209,13 @@ export class McpResponse implements Response { #page?: McpPage; #redactNetworkHeaders = true; #error?: Error; + #attachedWaitForResult?: WaitForEventsResult; #logCallback?: (message: string) => void; + get #deviceScope(): DevTools.CrUXManager.DeviceScope { + return this.#page?.viewport?.isMobile ? 'PHONE' : 'DESKTOP'; + } + constructor(args: ParsedArguments, logCallback?: (message: string) => void) { this.#args = args; this.#logCallback = logCallback; @@ -396,6 +402,10 @@ export class McpResponse implements Response { this.#textResponseLines.push(value); } + attachWaitForResult(result: WaitForEventsResult): void { + this.#attachedWaitForResult = result; + } + setHeapSnapshotAggregates( aggregates: Record< string, @@ -747,6 +757,8 @@ export class McpResponse implements Response { extensionServiceWorkers?: object[]; extensionPages?: object[]; errorMessage?: string; + navigatedToUrl?: string; + geolocation?: {latitude: number; longitude: number}; } = {}; const response = []; @@ -755,6 +767,16 @@ export class McpResponse implements Response { response.push(...this.#textResponseLines); } + if (this.#attachedWaitForResult) { + if (this.#attachedWaitForResult.navigatedToUrl) { + response.push( + `Page navigated to ${this.#attachedWaitForResult.navigatedToUrl}.`, + ); + structuredContent.navigatedToUrl = + this.#attachedWaitForResult.navigatedToUrl; + } + } + const networkConditions = this.#page?.networkConditions; if (networkConditions) { const timeout = this.#page!.pptrPage.getDefaultNavigationTimeout(); @@ -764,6 +786,14 @@ export class McpResponse implements Response { structuredContent.navigationTimeout = timeout; } + const geolocation = this.#page?.geolocation; + if (geolocation) { + response.push( + `Emulating geolocation: latitude=${geolocation.latitude}, longitude=${geolocation.longitude}`, + ); + structuredContent.geolocation = geolocation; + } + const viewport = this.#page?.viewport; if (viewport) { response.push(`Emulating viewport: ${JSON.stringify(viewport)}`); @@ -880,7 +910,7 @@ Call ${handleDialog.name} to handle it before continuing.`); } if (data.traceSummary) { - const summary = getTraceSummary(data.traceSummary); + const summary = getTraceSummary(data.traceSummary, this.#deviceScope); response.push(summary); structuredContent.traceSummary = summary; structuredContent.traceInsights = []; @@ -899,6 +929,7 @@ Call ${handleDialog.name} to handle it before continuing.`); data.traceInsight.trace, data.traceInsight.insightSetId, data.traceInsight.insightName, + this.#deviceScope, ); if ('error' in insightOutput) { response.push(insightOutput.error); @@ -975,12 +1006,20 @@ Call ${handleDialog.name} to handle it before continuing.`); } const nodes = this.#heapSnapshotOptions.nodes; if (nodes) { - const sortedItems = nodes.items - .filter(isNodeLike) - .sort((a, b) => b.retainedSize - a.retainedSize); + let items = Array.from(nodes.items); + const firstItem = nodes.items[0]; + if (firstItem) { + if (isNodeLike(firstItem)) { + items = items + .filter(isNodeLike) + .sort((a, b) => b.retainedSize - a.retainedSize); + } else if (isEdgeLike(firstItem)) { + items = items.filter(isEdgeLike); + } + } const paginationData = this.#dataWithPagination( - sortedItems, + items, this.#heapSnapshotOptions.pagination, ); diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index cec1d1d..80950c9 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -11,8 +11,9 @@ import {McpResponse} from './McpResponse.js'; import type {Mutex} from './Mutex.js'; import {SlimMcpResponse} from './SlimMcpResponse.js'; import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; -import {bucketizeLatency} from './telemetry/metricUtils.js'; -import type {CallToolResult, zod} from './third_party/index.js'; +import {bucketizeLatency} from './telemetry/transformation.js'; +import type {CallToolResult} from './third_party/index.js'; +import {zod} from './third_party/index.js'; import type {ToolCategory} from './tools/categories.js'; import {labels, OFF_BY_DEFAULT_CATEGORIES} from './tools/categories.js'; import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js'; @@ -121,8 +122,29 @@ function isPageScopedTool( return 'pageScoped' in tool && tool.pageScoped === true; } +function formatArgumentNames(names: string[]): string { + return names.map(name => `"${name}"`).join(', '); +} + +function buildUnknownArgumentsMessage( + toolName: string, + unknownArgumentNames: string[], + expectedArgumentNames: string[], +): string { + const unknownLabel = + unknownArgumentNames.length === 1 ? 'argument' : 'arguments'; + const expectedArguments = expectedArgumentNames.length + ? `Expected arguments: ${formatArgumentNames(expectedArgumentNames)}.` + : 'This tool does not accept any arguments.'; + const correction = + unknownArgumentNames.length === 1 ? 'Remove it' : 'Remove them'; + + return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`; +} + export class ToolHandler { readonly inputSchema: zod.ZodRawShape; + readonly registeredInputSchema: zod.ZodTypeAny; readonly shouldRegister: boolean; private readonly disabledReason?: string; @@ -141,8 +163,15 @@ export class ToolHandler { tool.pageScoped && serverArgs.experimentalPageIdRouting && !serverArgs.slim - ? {...tool.schema, ...pageIdSchema} + ? {...pageIdSchema, ...tool.schema} : tool.schema; + this.registeredInputSchema = zod.object(this.inputSchema).passthrough(); + } + + unknownArgumentNames(params: Record): string[] { + return Object.keys(params).filter( + key => !Object.hasOwn(this.inputSchema, key), + ); } async handle(params: Record): Promise { @@ -158,6 +187,23 @@ export class ToolHandler { }; } + const unknownArgumentNames = this.unknownArgumentNames(params); + if (unknownArgumentNames.length) { + return { + content: [ + { + type: 'text', + text: buildUnknownArgumentsMessage( + this.tool.name, + unknownArgumentNames, + Object.keys(this.inputSchema), + ), + }, + ], + isError: true, + }; + } + const guard = await this.toolMutex.acquire(); const startTime = Date.now(); let success = false; diff --git a/src/WaitForHelper.ts b/src/WaitForHelper.ts index 6bae227..545cd27 100644 --- a/src/WaitForHelper.ts +++ b/src/WaitForHelper.ts @@ -16,6 +16,9 @@ export class WaitForHelper { #expectNavigationIn: number; #navigationTimeout: number; + #dialogOpened = false; + #initialUrl: string; + constructor( page: Page, cpuTimeoutMultiplier: number, @@ -26,6 +29,7 @@ export class WaitForHelper { this.#expectNavigationIn = 100 * cpuTimeoutMultiplier; this.#navigationTimeout = 3000 * networkTimeoutMultiplier; this.#page = page as unknown as CdpPage; + this.#initialUrl = page.url(); } /** @@ -128,14 +132,12 @@ export class WaitForHelper { action: () => Promise, options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string}, ): Promise { - let dialogOpened = false; - const dialogWatcher = () => { - dialogOpened = true; - }; - this.#page.on('dialog', dialogWatcher); - + if (this.#abortController.signal.aborted) { + throw new Error("Can't re-use a WaitForHelper"); + } if (options?.handleDialog) { const dialogHandler = (dialog: Pick) => { + this.#dialogOpened = true; if (options.handleDialog === 'dismiss') { void dialog.dismiss(); } else if (options.handleDialog === 'accept') { @@ -150,11 +152,6 @@ export class WaitForHelper { }); } - this.#abortController.signal.addEventListener('abort', () => { - this.#page.off('dialog', dialogWatcher); - }); - - const urlBeforeAction = this.#page.url(); const navigationFinished = this.waitForNavigationStarted() .then(navigationStated => { if (navigationStated) { @@ -178,8 +175,8 @@ export class WaitForHelper { try { await navigationFinished; - if (dialogOpened) { - return {}; + if (this.#dialogOpened) { + return this.#getResult(); } // Wait for stable dom after navigation so we execute in @@ -192,9 +189,13 @@ export class WaitForHelper { this.#abortController.abort(); } + return this.#getResult(); + } + + #getResult(): WaitForEventsResult { const urlAfterAction = this.#page.url(); return { - ...(urlAfterAction !== urlBeforeAction + ...(urlAfterAction !== this.#initialUrl ? {navigatedToUrl: urlAfterAction} : {}), }; @@ -209,15 +210,6 @@ export interface WaitForEventsResult { navigatedToUrl?: string; } -export function appendWaitForResult( - response: {appendResponseLine(value: string): void}, - result: WaitForEventsResult, -): void { - if (result.navigatedToUrl) { - response.appendResponseLine(`Page navigated to ${result.navigatedToUrl}.`); - } -} - export function getNetworkMultiplierFromString( condition: string | null, ): number { diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index 7ef2827..b1aff3a 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -112,7 +112,7 @@ export const commands: Commands = { name: 'geolocation', type: 'string', description: - 'Geolocation (`x`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.', + 'Geolocation (`,`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.', required: false, }, userAgent: { @@ -137,6 +137,13 @@ export const commands: Commands = { "Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.", required: false, }, + extraHttpHeaders: { + name: 'extraHttpHeaders', + type: 'string', + description: + 'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.', + required: false, + }, }, }, evaluate_script: { @@ -157,6 +164,13 @@ export const commands: Commands = { description: 'An optional list of arguments to pass to the function.', required: false, }, + filePath: { + name: 'filePath', + type: 'string', + description: + 'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.', + required: false, + }, dialogAction: { name: 'dialogAction', type: 'string', @@ -208,6 +222,106 @@ export const commands: Commands = { }, }, }, + get_heapsnapshot_class_nodes: { + description: + 'Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --experimentalMemory=true)', + category: 'Memory', + args: { + filePath: { + name: 'filePath', + type: 'string', + description: 'A path to a .heapsnapshot file to read.', + required: true, + }, + id: { + name: 'id', + type: 'number', + description: 'The ID for the class, obtained from details.', + required: true, + }, + pageIdx: { + name: 'pageIdx', + type: 'number', + description: 'The page index for pagination.', + required: false, + }, + pageSize: { + name: 'pageSize', + type: 'number', + description: 'The page size for pagination.', + required: false, + }, + }, + }, + get_heapsnapshot_details: { + description: + 'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates. (requires flag: --experimentalMemory=true)', + category: 'Memory', + args: { + filePath: { + name: 'filePath', + type: 'string', + description: 'A path to a .heapsnapshot file to read.', + required: true, + }, + pageIdx: { + name: 'pageIdx', + type: 'number', + description: 'The page index for pagination of aggregates.', + required: false, + }, + pageSize: { + name: 'pageSize', + type: 'number', + description: 'The page size for pagination of aggregates.', + required: false, + }, + }, + }, + get_heapsnapshot_retainers: { + description: + 'Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true)', + category: 'Memory', + args: { + filePath: { + name: 'filePath', + type: 'string', + description: 'A path to a .heapsnapshot file to read.', + required: true, + }, + nodeId: { + name: 'nodeId', + type: 'number', + description: 'The node ID to get retainers for.', + required: true, + }, + pageIdx: { + name: 'pageIdx', + type: 'number', + description: 'The page index for pagination.', + required: false, + }, + pageSize: { + name: 'pageSize', + type: 'number', + description: 'The page size for pagination.', + required: false, + }, + }, + }, + get_heapsnapshot_summary: { + description: + 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)', + category: 'Memory', + args: { + filePath: { + name: 'filePath', + type: 'string', + description: 'A path to a .heapsnapshot file to read.', + required: true, + }, + }, + }, get_network_request: { description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.', @@ -383,6 +497,12 @@ export const commands: Commands = { category: 'Navigation automation', args: {}, }, + list_webmcp_tools: { + description: + 'Lists all WebMCP tools the page exposes. (requires flag: --categoryExperimentalWebmcp=true)', + category: 'WebMCP', + args: {}, + }, navigate_page: { description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.', @@ -650,7 +770,7 @@ export const commands: Commands = { }, }, }, - take_memory_snapshot: { + take_heapsnapshot: { description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.', category: 'Memory', diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 51d94ca..99d5486 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -150,7 +150,7 @@ export const cliOptions = { experimentalPageIdRouting: { type: 'boolean', describe: - 'Whether to expose pageId on page-scoped tools and route requests by page ID.', + 'Whether to expose pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions).', hidden: true, }, experimentalDevtools: { @@ -284,7 +284,7 @@ export const cliOptions = { redactNetworkHeaders: { type: 'boolean', describe: - 'If true, redacts some of the network headers considered senstive before returning to the client.', + 'If true, redacts some of the network headers considered sensitive before returning to the client.', default: false, }, } satisfies Record; diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index 1d03c08..25fb69b 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -8,6 +8,7 @@ import '../polyfill.js'; import process from 'node:process'; +import {closeBrowser} from '../browser.js'; import {createMcpServer, logDisclaimers} from '../index.js'; import {logger, saveLogsToFile} from '../logger.js'; import {ClearcutLogger} from '../telemetry/ClearcutLogger.js'; @@ -32,6 +33,44 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') { }); } +// Shutdown on stdin EOF (stdio MCP convention — the client closes the +// transport to signal exit) and on standard termination signals. Without +// this, an active Chrome subprocess keeps the Node event loop ref'd after +// stdin closes and the server hangs until something else kills it. +let shuttingDown = false; +async function shutdown(reason: string): Promise { + if (shuttingDown) { + return; + } + shuttingDown = true; + logger(`Shutting down (${reason})`); + // Backstop in case browser teardown hangs (e.g. unresponsive Chrome, + // slow beforeunload handlers, many tabs). Exits 0 because we still + // honored the shutdown request; the log line preserves observability. + // Unref'd so it doesn't keep the loop alive on the clean path. + setTimeout(() => { + logger('Shutdown timeout exceeded, forcing exit'); + process.exit(0); + }, 10000).unref(); + await closeBrowser(); + process.exit(0); +} +process.stdin.on('end', () => { + void shutdown('stdin end'); +}); +process.stdin.on('close', () => { + void shutdown('stdin close'); +}); +process.on('SIGTERM', () => { + void shutdown('SIGTERM'); +}); +process.on('SIGINT', () => { + void shutdown('SIGINT'); +}); +process.on('SIGHUP', () => { + void shutdown('SIGHUP'); +}); + logger(`Starting Chrome DevTools MCP Server v${VERSION}`); const {server} = await createMcpServer(args, { logFile, diff --git a/src/browser.ts b/src/browser.ts index ade5c09..5ce73ab 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -21,6 +21,7 @@ import type { import {puppeteer} from './third_party/index.js'; let browser: Browser | undefined; +let browserMode: 'launched' | 'connected' | undefined; function makeTargetFilter(enableExtensions = false) { const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']); @@ -122,7 +123,12 @@ export async function ensureBrowserConnected(options: { logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); try { - browser = await puppeteer.connect(connectOptions); + // Assign mode before browser so a concurrent closeBrowser() never sees + // `browser` set with `browserMode` still undefined (would fall through + // to the disconnect() path and orphan a launched Chrome). + const connected = await puppeteer.connect(connectOptions); + browserMode = 'connected'; + browser = connected; } catch (err) { throw new Error( `Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, @@ -268,7 +274,10 @@ export async function ensureBrowserLaunched( if (browser?.connected) { return browser; } - browser = await launch(options); + // Assign mode before browser; see the connect path above for rationale. + const launched = await launch(options); + browserMode = 'launched'; + browser = launched; return browser; } @@ -276,15 +285,29 @@ export function getCurrentBrowser(): Browser | undefined { return browser; } -export async function closeBrowserIfOpen(): Promise { - if (browser?.connected) { - try { - await browser.close(); - } catch { - // ignore — browser may already be closed - } - } +/** + * Shutdown hook for the active browser. Closes a launched browser (so the + * Chrome subprocess is reaped) or disconnects from an attached browser (so + * the user's Chrome instance stays alive). No-op if no browser is active or + * the connection has already been dropped. + */ +export async function closeBrowser(): Promise { + const b = browser; + const mode = browserMode; browser = undefined; + browserMode = undefined; + if (!b || !b.connected) { + return; + } + if (mode === 'launched') { + await b.close().catch(err => { + logger('Failed to close browser', err); + }); + return; + } + await b.disconnect().catch(err => { + logger('Failed to disconnect from browser', err); + }); } export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 61d921b..40ddf93 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -154,13 +154,8 @@ export async function handleResponse( if (response.isError) { return JSON.stringify(response.content); } - if (format === 'json') { - if (response.structuredContent) { - return JSON.stringify(response.structuredContent); - } - // Fall-through to text for backward compatibility. - } const chunks = []; + const images: Array<{filePath: string; mimeType: string}> = []; for (const content of response.content) { if (content.type === 'text') { chunks.push(content.text); @@ -181,10 +176,21 @@ export async function handleResponse( const name = crypto.randomUUID(); const filepath = await getTempFilePath(`${name}${extension}`); fs.writeFileSync(filepath, data); + images.push({filePath: filepath, mimeType}); chunks.push(`Saved to ${filepath}.`); } else { throw new Error('Not supported response content type'); } } + if (format === 'json') { + if (response.structuredContent) { + const structuredContent = { + ...response.structuredContent, + ...(images.length ? {images} : {}), + }; + return JSON.stringify(structuredContent); + } + // Fall-through to text for backward compatibility. + } return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks); } diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index e6cb8bd..6708f31 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -6,8 +6,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import fs from 'node:fs'; +import fs, {constants, openSync, writeSync, closeSync} from 'node:fs'; import {createServer, type Server} from 'node:net'; +import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; @@ -36,10 +37,82 @@ if (isDaemonRunning(sessionId)) { process.exit(1); } const pidFilePath = getPidFilePath(sessionId); -fs.mkdirSync(path.dirname(pidFilePath), { - recursive: true, -}); -fs.writeFileSync(pidFilePath, process.pid.toString()); +const pidDir = path.dirname(pidFilePath); +const currentUserUid = os.userInfo().uid; + +try { + fs.mkdirSync(pidDir, {recursive: true}); + if (os.platform() !== 'win32') { + // POSIX specific checks + try { + const stats = fs.statSync(pidDir); + + // 1. Check Ownership: Ensure the directory is owned by the current user. + if (stats.uid !== currentUserUid) { + console.error( + `[MCP Daemon] Critical error: PID directory ${pidDir} is not owned by the current user (Expected: ${currentUserUid}, Found: ${stats.uid}). Possible tampering.`, + ); + process.exit(1); + } + + // 2. Check Permissions: Ensure the directory is not group or world-writable. + // Mode is a number, e.g., 0o700. We check if bits for group/world write are set. + const mode = stats.mode; + if (mode & constants.S_IWGRP || mode & constants.S_IWOTH) { + console.error( + `[MCP Daemon] Critical error: PID directory ${pidDir} has insecure permissions (Mode: ${mode.toString(8)}). It should not be writable by group or others.`, + ); + process.exit(1); + } + } catch (statErr) { + console.error( + `[MCP Daemon] Critical error stating PID directory ${pidDir}:`, + statErr, + ); + process.exit(1); + } + } +} catch (err) { + console.error( + `[MCP Daemon] Critical error creating/validating PID directory: ${pidDir}`, + err, + ); + process.exit(1); +} + +let fd = -1; +try { + // Open the file with flags to: + // - O_WRONLY: Write-only + // - O_CREAT: Create if it doesn't exist + // - O_TRUNC: Truncate to zero length if it exists + // - O_NOFOLLOW: DO NOT follow symlinks. + // - 0o600: Permissions: read/write for owner, no permissions for others. + fd = openSync( + pidFilePath, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_TRUNC | + constants.O_NOFOLLOW, + 0o600, + ); + writeSync(fd, process.pid.toString()); +} catch (err) { + console.error( + `[MCP Daemon] Critical error writing PID file: ${pidFilePath}`, + err, + ); + // If openSync fails due to O_NOFOLLOW on a symlink, the error will be caught here. + process.exit(1); +} finally { + if (fd !== -1) { + try { + closeSync(fd); + } catch (err) { + console.error(`[MCP Daemon] Error closing PID file: ${pidFilePath}`, err); + } + } +} logger(`Writing ${process.pid.toString()} to ${pidFilePath}`); const socketPath = getSocketPath(sessionId); diff --git a/src/formatters/HeapSnapshotFormatter.ts b/src/formatters/HeapSnapshotFormatter.ts index 20ee4c9..e5f0a77 100644 --- a/src/formatters/HeapSnapshotFormatter.ts +++ b/src/formatters/HeapSnapshotFormatter.ts @@ -4,16 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js'; -import type {DevTools} from '../third_party/index.js'; +import type {AggregatedInfoWithId} from '../HeapSnapshotManager.js'; +import {DevTools} from '../third_party/index.js'; import {stableIdSymbol} from '../utils/id.js'; export interface FormattedSnapshotEntry { className: string; - classUid?: number; + id?: number; count: number; - selfSize: number; - retainedSize: number; + selfSize: string; + retainedSize: string; } export function isNodeLike( @@ -24,10 +24,26 @@ export function isNodeLike( ); } +export function isEdgeLike( + item: unknown, +): item is DevTools.HeapSnapshotModel.HeapSnapshotModel.Edge { + return ( + typeof item === 'object' && + item !== null && + 'name' in item && + 'node' in item && + 'type' in item && + typeof item.node === 'object' && + item.node !== null && + 'id' in item.node && + 'name' in item.node + ); +} + export class HeapSnapshotFormatter { - #aggregates: Record; + #aggregates: Record; - constructor(aggregates: Record) { + constructor(aggregates: Record) { this.#aggregates = aggregates; } @@ -39,14 +55,23 @@ export class HeapSnapshotFormatter { ): string { const lines: string[] = []; - if (items.length > 0 && isNodeLike(items[0])) { - lines.push('id,name,type,distance,selfSize,retainedSize'); + if (items.length > 0) { + const firstItem = items[0]; + if (isNodeLike(firstItem)) { + lines.push('nodeId,nodeName,type,distance,selfSize,retainedSize'); + } else if (isEdgeLike(firstItem)) { + lines.push('name,type,nodeId,nodeName'); + } } for (const item of items) { if (isNodeLike(item)) { lines.push( - `${item.id},"${item.name}",${item.type},${item.distance},${item.selfSize},${item.retainedSize}`, + `${item.id},${item.name},${item.type},${item.distance},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.selfSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.retainedSize)}`, + ); + } else if (isEdgeLike(item)) { + lines.push( + `${item.name},${item.type},${item.node.id},${item.node.name}`, ); } } @@ -54,19 +79,19 @@ export class HeapSnapshotFormatter { return lines.join('\n'); } - #getSortedAggregates(): AggregatedInfoWithUid[] { + #getSortedAggregates(): AggregatedInfoWithId[] { return Object.values(this.#aggregates).sort((a, b) => b.maxRet - a.maxRet); } toString(): string { const sorted = this.#getSortedAggregates(); const lines: string[] = []; - lines.push('uid,className,count,selfSize,maxRetainedSize'); + lines.push('id,name,count,selfSize,maxRetainedSize'); for (const info of sorted) { - const uid = info[stableIdSymbol] ?? ''; + const id = info[stableIdSymbol] ?? ''; lines.push( - `${uid},"${info.name}",${info.count},${info.self},${info.maxRet}`, + `${id},${info.name},${info.count},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.self)},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet)}`, ); } @@ -76,11 +101,11 @@ export class HeapSnapshotFormatter { toJSON(): FormattedSnapshotEntry[] { const sorted = this.#getSortedAggregates(); return sorted.map(info => ({ - uid: info[stableIdSymbol], + id: info[stableIdSymbol], className: info.name, count: info.count, - selfSize: info.self, - retainedSize: info.maxRet, + selfSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.self), + retainedSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet), })); } diff --git a/src/index.ts b/src/index.ts index 8049b39..3b8049d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import type fs from 'node:fs'; import type {parseArguments} from './bin/opera-devtools-mcp-cli-options.js'; import type {Channel} from './browser.js'; import { - closeBrowserIfOpen, + closeBrowser, ensureBrowserConnected, ensureBrowserLaunched, getCurrentBrowser, @@ -23,7 +23,7 @@ import {McpResponse} from './McpResponse.js'; import {Mutex} from './Mutex.js'; import {SlimMcpResponse} from './SlimMcpResponse.js'; import type {ClearcutLogger} from './telemetry/ClearcutLogger.js'; -import {bucketizeLatency} from './telemetry/metricUtils.js'; +import {bucketizeLatency} from './telemetry/transformation.js'; import { McpServer, type CallToolResult, @@ -145,15 +145,14 @@ export async function createMcpServer( context?.dispose(); context = undefined; browserHasOperaFlags = false; - await closeBrowserIfOpen(); - // getContext() will relaunch without --disable-blink-features=AutomationControlled + await closeBrowser(); } async function restartBrowserForOpera(): Promise { context?.dispose(); context = undefined; browserHasOperaFlags = false; - await closeBrowserIfOpen(); + await closeBrowser(); const chromeArgs: string[] = [ '--disable-blink-features=AutomationControlled', ...(serverArgs.chromeArg ?? []).map(String), diff --git a/src/telemetry/ClearcutLogger.ts b/src/telemetry/ClearcutLogger.ts index 3855fe9..8586533 100644 --- a/src/telemetry/ClearcutLogger.ts +++ b/src/telemetry/ClearcutLogger.ts @@ -12,6 +12,7 @@ import type {zod, ShapeOutput} from '../third_party/index.js'; import type {ErrorCode} from './errors.js'; import type {LocalState, Persistence} from './persistence.js'; +import {sanitizeParams, stripUnderscoreBeforeNumber} from './transformation.js'; import { McpClient, type FlagUsage, @@ -22,141 +23,6 @@ import { import {WatchdogClient} from './WatchdogClient.js'; const MS_PER_DAY = 24 * 60 * 60 * 1000; -export const PARAM_BLOCKLIST = new Set(['uid', 'reqid', 'msgid']); - -const SUPPORTED_ZOD_TYPES = [ - 'ZodString', - 'ZodNumber', - 'ZodBoolean', - 'ZodArray', - 'ZodEnum', -] as const; -type ZodType = (typeof SUPPORTED_ZOD_TYPES)[number]; - -function isZodType(type: string): type is ZodType { - return SUPPORTED_ZOD_TYPES.includes(type as ZodType); -} - -export function getZodType(zodType: zod.ZodTypeAny): ZodType { - const def = zodType._def; - const typeName = def.typeName; - - if ( - typeName === 'ZodOptional' || - typeName === 'ZodDefault' || - typeName === 'ZodNullable' - ) { - return getZodType(def.innerType); - } - if (typeName === 'ZodEffects') { - return getZodType(def.schema); - } - - if (isZodType(typeName)) { - return typeName; - } - throw new Error(`Unsupported zod type for tool parameter: ${typeName}`); -} - -type LoggedToolCallArgValue = string | number | boolean; - -export function transformArgName(zodType: ZodType, name: string): string { - const snakeCaseName = name.replace( - /[A-Z]/g, - letter => `_${letter.toLowerCase()}`, - ); - if (zodType === 'ZodString') { - return `${snakeCaseName}_length`; - } else if (zodType === 'ZodArray') { - return `${snakeCaseName}_count`; - } else { - return snakeCaseName; - } -} - -export function transformArgType(zodType: ZodType): string { - if (zodType === 'ZodString' || zodType === 'ZodArray') { - return 'number'; - } - switch (zodType) { - case 'ZodNumber': - return 'number'; - case 'ZodBoolean': - return 'boolean'; - case 'ZodEnum': - return 'enum'; - default: - throw new Error(`Unsupported zod type for tool parameter: ${zodType}`); - } -} - -const BUCKETS = [ - 0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, -]; - -function bucketize(value: number): number { - for (const bucket of BUCKETS) { - if (bucket >= value) { - return bucket; - } - } - return BUCKETS[BUCKETS.length - 1]; -} - -function transformValue( - zodType: ZodType, - value: unknown, -): LoggedToolCallArgValue { - if (zodType === 'ZodString') { - return bucketize((value as string).length); - } else if (zodType === 'ZodArray') { - return (value as unknown[]).length; - } else { - return value as LoggedToolCallArgValue; - } -} - -function hasEquivalentType(zodType: ZodType, value: unknown): boolean { - if (zodType === 'ZodString') { - return typeof value === 'string'; - } else if (zodType === 'ZodArray') { - return Array.isArray(value); - } else if (zodType === 'ZodNumber') { - return typeof value === 'number'; - } else if (zodType === 'ZodBoolean') { - return typeof value === 'boolean'; - } else if (zodType === 'ZodEnum') { - return ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ); - } else { - return false; - } -} - -export function sanitizeParams( - params: ShapeOutput, - schema: zod.ZodRawShape, -): ShapeOutput { - const transformed: ShapeOutput = {}; - for (const [name, value] of Object.entries(params)) { - if (PARAM_BLOCKLIST.has(name)) { - continue; - } - const zodType = getZodType(schema[name]); - if (!hasEquivalentType(zodType, value)) { - throw new Error( - `parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`, - ); - } - const transformedName = transformArgName(zodType, name); - const transformedValue = transformValue(zodType, value); - transformed[transformedName] = transformedValue; - } - return transformed; -} function detectOsType(): OsType { switch (process.platform) { @@ -247,14 +113,18 @@ export class ClearcutLogger { success: boolean; latencyMs: number; }): Promise { + const sanitizedToolName = stripUnderscoreBeforeNumber(args.toolName); const tool_invocation: ToolInvocation = { - tool_name: args.toolName, + tool_name: sanitizedToolName, success: args.success, latency_ms: args.latencyMs, }; if (Object.keys(args.params).length > 0) { tool_invocation.tool_params = { - [`${args.toolName}_params`]: sanitizeParams(args.params, args.schema), + [`${sanitizedToolName}_params`]: sanitizeParams( + args.params, + args.schema, + ), }; } @@ -319,7 +189,9 @@ export class ClearcutLogger { payload: { mcp_client: this.#mcpClient, server_error: { - tool_name: args.toolName ?? '', + tool_name: args.toolName + ? stripUnderscoreBeforeNumber(args.toolName) + : '', error_code: args.errorCode, }, }, diff --git a/src/telemetry/errors.ts b/src/telemetry/errors.ts index a91addf..f1bd797 100644 --- a/src/telemetry/errors.ts +++ b/src/telemetry/errors.ts @@ -13,5 +13,5 @@ export enum ErrorCode { ERROR_CODE_UNSPECIFIED = 0, ERROR_CODE_PERSISTENCE_FILE_READ_FAILED = 1, - ERROR_CODE_PERSISTENCE_FILE_SAVE_FAILED = 3, + ERROR_CODE_PERSISTENCE_FILE_SAVE_FAILED = 2, } diff --git a/src/telemetry/flagUtils.ts b/src/telemetry/flagUtils.ts index 497dabd..ae3f8e4 100644 --- a/src/telemetry/flagUtils.ts +++ b/src/telemetry/flagUtils.ts @@ -9,6 +9,7 @@ import type {cliOptions} from '../bin/opera-devtools-mcp-cli-options.js'; import {toSnakeCase} from '../utils/string.js'; +import {stripUnderscoreBeforeNumber} from './transformation.js'; import type {FlagUsage} from './types.js'; type CliOptions = typeof cliOptions; @@ -19,7 +20,9 @@ type CliOptions = typeof cliOptions; * as an `enum` where the keys of the enum will map to the uppercase `choice`. */ function formatEnumChoice(snakeCaseName: string, choice: string): string { - return `${snakeCaseName}_${choice}`.toUpperCase(); + return stripUnderscoreBeforeNumber( + `${snakeCaseName}_${choice}`, + ).toUpperCase(); } /** @@ -43,7 +46,7 @@ export function computeFlagUsage( for (const [flagName, config] of Object.entries(options)) { const value = args[flagName]; - const snakeCaseName = toSnakeCase(flagName); + const snakeCaseName = stripUnderscoreBeforeNumber(toSnakeCase(flagName)); // If there isn't a default value provided for the flag, // we're going to log whether it's present on the args user @@ -84,7 +87,7 @@ export function getPossibleFlagMetrics(options: CliOptions): FlagMetric[] { const metrics: FlagMetric[] = []; for (const [flagName, config] of Object.entries(options)) { - const snakeCaseName = toSnakeCase(flagName); + const snakeCaseName = stripUnderscoreBeforeNumber(toSnakeCase(flagName)); // _present is always a possible metric metrics.push({ diff --git a/src/telemetry/metricUtils.ts b/src/telemetry/metricUtils.ts deleted file mode 100644 index 55e834f..0000000 --- a/src/telemetry/metricUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -const LATENCY_BUCKETS = [50, 100, 250, 500, 1000, 2500, 5000, 10000]; - -export function bucketizeLatency(latencyMs: number): number { - for (const bucket of LATENCY_BUCKETS) { - if (latencyMs <= bucket) { - return bucket; - } - } - return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1]; -} diff --git a/src/telemetry/toolMetricsUtils.ts b/src/telemetry/metricsRegistry.ts similarity index 96% rename from src/telemetry/toolMetricsUtils.ts rename to src/telemetry/metricsRegistry.ts index 74f2af4..366f47a 100644 --- a/src/telemetry/toolMetricsUtils.ts +++ b/src/telemetry/metricsRegistry.ts @@ -11,7 +11,8 @@ import { transformArgType, getZodType, PARAM_BLOCKLIST, -} from './ClearcutLogger.js'; + stripUnderscoreBeforeNumber, +} from './transformation.js'; /** * Validates that all values in an enum are of the homogeneous primitive type. @@ -118,7 +119,7 @@ export function generateToolMetrics(tools: ToolDefinition[]): ToolMetric[] { } return { - name: tool.name, + name: stripUnderscoreBeforeNumber(tool.name), args, }; }); diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index be3e38a..3b5b4d6 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -85,6 +85,10 @@ { "name": "viewport_length", "argType": "number" + }, + { + "name": "extra_http_headers_length", + "argType": "number" } ] }, @@ -111,6 +115,10 @@ { "name": "dialog_action_length", "argType": "number" + }, + { + "name": "file_path_length", + "argType": "number" } ] }, @@ -458,7 +466,8 @@ "name": "file_path_length", "argType": "number" } - ] + ], + "isDeprecated": true }, { "name": "take_screenshot", @@ -575,7 +584,8 @@ "name": "file_path_length", "argType": "number" } - ] + ], + "isDeprecated": true }, { "name": "get_memory_snapshot_details", @@ -592,7 +602,8 @@ "name": "page_size", "argType": "number" } - ] + ], + "isDeprecated": true }, { "name": "get_nodes_by_class", @@ -609,10 +620,11 @@ "name": "page_size", "argType": "number" } - ] + ], + "isDeprecated": true }, { - "name": "execute_3p_developer_tool", + "name": "execute3p_developer_tool", "args": [ { "name": "tool_name_length", @@ -625,9 +637,108 @@ ] }, { - "name": "list_3p_developer_tools", + "name": "list3p_developer_tools", "args": [] }, + { + "name": "get_node_retainers", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "node_id", + "argType": "number" + }, + { + "name": "page_idx", + "argType": "number" + }, + { + "name": "page_size", + "argType": "number" + } + ], + "isDeprecated": true + }, + { + "name": "get_heapsnapshot_class_nodes", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "page_idx", + "argType": "number" + }, + { + "name": "page_size", + "argType": "number" + }, + { + "name": "id", + "argType": "number" + } + ] + }, + { + "name": "get_heapsnapshot_details", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "page_idx", + "argType": "number" + }, + { + "name": "page_size", + "argType": "number" + } + ] + }, + { + "name": "get_heapsnapshot_retainers", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "node_id", + "argType": "number" + }, + { + "name": "page_idx", + "argType": "number" + }, + { + "name": "page_size", + "argType": "number" + } + ] + }, + { + "name": "get_heapsnapshot_summary", + "args": [ + { + "name": "file_path_length", + "argType": "number" + } + ] + }, + { + "name": "take_heapsnapshot", + "args": [ + { + "name": "file_path_length", + "argType": "number" + } + ] + }, { "name": "opera_chat", "args": [ diff --git a/src/telemetry/transformation.ts b/src/telemetry/transformation.ts new file mode 100644 index 0000000..c7b7dae --- /dev/null +++ b/src/telemetry/transformation.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {zod, ShapeOutput} from '../third_party/index.js'; + +const LATENCY_BUCKETS = [50, 100, 250, 500, 1000, 2500, 5000, 10000]; + +export function bucketizeLatency(latencyMs: number): number { + for (const bucket of LATENCY_BUCKETS) { + if (latencyMs <= bucket) { + return bucket; + } + } + return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1]; +} + +export const PARAM_BLOCKLIST = new Set(['uid', 'reqid', 'msgid']); + +const SUPPORTED_ZOD_TYPES = [ + 'ZodString', + 'ZodNumber', + 'ZodBoolean', + 'ZodArray', + 'ZodEnum', +] as const; +type ZodType = (typeof SUPPORTED_ZOD_TYPES)[number]; + +function isZodType(type: string): type is ZodType { + return SUPPORTED_ZOD_TYPES.includes(type as ZodType); +} + +export function getZodType(zodType: zod.ZodTypeAny): ZodType { + const def = zodType._def; + const typeName = def.typeName; + + if ( + typeName === 'ZodOptional' || + typeName === 'ZodDefault' || + typeName === 'ZodNullable' + ) { + return getZodType(def.innerType); + } + if (typeName === 'ZodEffects') { + return getZodType(def.schema); + } + + if (isZodType(typeName)) { + return typeName; + } + throw new Error(`Unsupported zod type for tool parameter: ${typeName}`); +} + +export function stripUnderscoreBeforeNumber(name: string): string { + return name.replace(/_([0-9])/g, '$1'); +} + +type LoggedToolCallArgValue = string | number | boolean; + +export function transformArgName(zodType: ZodType, name: string): string { + const snakeCaseName = name.replace( + /[A-Z]/g, + letter => `_${letter.toLowerCase()}`, + ); + let transformed: string; + if (zodType === 'ZodString') { + transformed = `${snakeCaseName}_length`; + } else if (zodType === 'ZodArray') { + transformed = `${snakeCaseName}_count`; + } else { + transformed = snakeCaseName; + } + return stripUnderscoreBeforeNumber(transformed); +} + +export function transformArgType(zodType: ZodType): string { + if (zodType === 'ZodString' || zodType === 'ZodArray') { + return 'number'; + } + switch (zodType) { + case 'ZodNumber': + return 'number'; + case 'ZodBoolean': + return 'boolean'; + case 'ZodEnum': + return 'enum'; + default: + throw new Error(`Unsupported zod type for tool parameter: ${zodType}`); + } +} + +const BUCKETS = [ + 0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, +]; + +function bucketize(value: number): number { + for (const bucket of BUCKETS) { + if (bucket >= value) { + return bucket; + } + } + return BUCKETS[BUCKETS.length - 1]; +} + +function transformValue( + zodType: ZodType, + value: unknown, +): LoggedToolCallArgValue { + if (zodType === 'ZodString') { + return bucketize((value as string).length); + } else if (zodType === 'ZodArray') { + return (value as unknown[]).length; + } else { + return value as LoggedToolCallArgValue; + } +} + +function hasEquivalentType(zodType: ZodType, value: unknown): boolean { + if (zodType === 'ZodString') { + return typeof value === 'string'; + } else if (zodType === 'ZodArray') { + return Array.isArray(value); + } else if (zodType === 'ZodNumber') { + return typeof value === 'number'; + } else if (zodType === 'ZodBoolean') { + return typeof value === 'boolean'; + } else if (zodType === 'ZodEnum') { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); + } else { + return false; + } +} + +export function sanitizeParams( + params: ShapeOutput, + schema: zod.ZodRawShape, +): ShapeOutput { + const transformed: ShapeOutput = {}; + for (const [name, value] of Object.entries(params)) { + if (PARAM_BLOCKLIST.has(name)) { + continue; + } + const zodType = getZodType(schema[name]); + if (!hasEquivalentType(zodType, value)) { + throw new Error( + `parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`, + ); + } + const transformedName = transformArgName(zodType, name); + const transformedValue = transformValue(zodType, value); + transformed[transformedName] = transformedValue; + } + return transformed; +} diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 6fca75e..507b5f1 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -7,7 +7,7 @@ */ import type {ParsedArguments} from '../bin/opera-devtools-mcp-cli-options.js'; -import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js'; +import type {AggregatedInfoWithId} from '../HeapSnapshotManager.js'; import type {McpPage} from '../McpPage.js'; import {zod} from '../third_party/index.js'; import type { @@ -158,6 +158,7 @@ export interface Response { attachLighthouseResult(result: LighthouseData): void; setListThirdPartyDeveloperTools(): void; setListWebMcpTools(): void; + attachWaitForResult(result: WaitForEventsResult): void; } export type SupportedExtensions = @@ -176,7 +177,7 @@ export type SupportedExtensions = * Only add methods used by tools/*. */ export type Context = Readonly<{ - validatePath(filePath?: string): void; + validatePath(filePath?: string): Promise; isRunningPerformanceTrace(): boolean; setIsRunningPerformanceTrace(x: boolean): void; isCruxEnabled(): boolean; @@ -238,16 +239,20 @@ export type Context = Readonly<{ ): string | undefined; getHeapSnapshotAggregates( filePath: string, - ): Promise>; + ): Promise>; getHeapSnapshotStats( filePath: string, ): Promise; getHeapSnapshotStaticData( filePath: string, ): Promise; - getHeapSnapshotNodesByUid( + getHeapSnapshotNodesById( filePath: string, - uid: number, + id: number, + ): Promise; + getHeapSnapshotRetainers( + filePath: string, + nodeId: number, ): Promise; }>; @@ -256,6 +261,8 @@ export type Context = Readonly<{ */ export type ContextPage = Readonly<{ readonly pptrPage: Page; + readonly cpuThrottlingRate: number; + readonly networkConditions: string | null; getAXNodeByUid(uid: string): TextSnapshotNode | undefined; getElementByUid(uid: string): Promise>; @@ -364,7 +371,7 @@ export const CLOSE_PAGE_ERROR = 'The last open page cannot be closed. It is fine to keep it open.'; export const pageIdSchema = { - pageId: zod.number().optional().describe('Targets a specific page by ID.'), + pageId: zod.number().describe('Targets a specific page by ID.'), }; export const timeoutSchema = { @@ -416,7 +423,7 @@ export function geolocationTransform(arg: string | undefined) { if (!arg) { return undefined; } - const [latitude, longitude] = arg.split('x').map(Number) as [number, number]; + const [latitude, longitude] = arg.split(',').map(Number) as [number, number]; return { latitude, longitude, diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index 2dbb026..519ad5a 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -14,6 +14,32 @@ import { viewportTransform, } from './ToolDefinition.js'; +function headerStringTransform( + value: string | undefined, +): Record | undefined { + if (value === undefined) { + return undefined; + } + if (value === '') { + return {}; + } + try { + const parsed = JSON.parse(value); + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error('Headers must be a JSON object'); + } + return parsed as Record; + } catch (error) { + throw new Error( + `Invalid JSON for headers: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + const throttlingOptions: [string, ...string[]] = [ 'Offline', ...Object.keys(PredefinedNetworkConditions), @@ -44,7 +70,7 @@ export const emulate = definePageTool({ .optional() .transform(geolocationTransform) .describe( - 'Geolocation (`x`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.', + 'Geolocation (`,`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.', ), userAgent: zod .string() @@ -65,10 +91,18 @@ export const emulate = definePageTool({ .describe( `Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.`, ), + extraHttpHeaders: zod + .string() + .optional() + .transform(headerStringTransform) + .describe( + 'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.', + ), }, blockedByDialog: true, - handler: async (request, _response, context) => { + handler: async (request, response, context) => { const page = request.page; await context.emulate(request.params, page.pptrPage); + response.appendResponseLine('Emulation configured successfully'); }, }); diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 58460ae..f0656de 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -24,6 +24,7 @@ export const installExtension = defineTool({ blockedByDialog: false, handler: async (request, response, context) => { const {path} = request.params; + await context.validatePath(path); const id = await context.installExtension(path); response.appendResponseLine(`Extension installed. Id: ${id}`); }, @@ -79,6 +80,7 @@ export const reloadExtension = defineTool({ if (!extension) { throw new Error(`Extension with ID ${id} not found.`); } + await context.validatePath(extension.path); await context.installExtension(extension.path); response.appendResponseLine('Extension reloaded.'); }, diff --git a/src/tools/input.ts b/src/tools/input.ts index d7fc8bc..7ff11e4 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -10,10 +10,7 @@ import {zod} from '../third_party/index.js'; import type {ElementHandle, KeyInput} from '../third_party/index.js'; import type {TextSnapshotNode} from '../types.js'; import {parseKey} from '../utils/keyboard.js'; -import { - appendWaitForResult, - type WaitForEventsResult, -} from '../WaitForHelper.js'; +import type {WaitForEventsResult} from '../WaitForHelper.js'; import {ToolCategory} from './categories.js'; import type {ContextPage} from './ToolDefinition.js'; @@ -130,7 +127,7 @@ export const click = definePageTool({ ? `Successfully double clicked on the element` : `Successfully clicked on the element`, ); - appendWaitForResult(response, result); + response.attachWaitForResult(result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -161,7 +158,7 @@ export const clickAt = definePageTool({ const page = request.page; const result = await page.waitForEventsAfterAction(async () => { await page.pptrPage.mouse.click(request.params.x, request.params.y, { - clickCount: request.params.dblClick ? 2 : 1, + count: request.params.dblClick ? 2 : 1, }); }); response.appendResponseLine( @@ -169,7 +166,7 @@ export const clickAt = definePageTool({ ? `Successfully double clicked at the coordinates` : `Successfully clicked at the coordinates`, ); - appendWaitForResult(response, result); + response.attachWaitForResult(result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -200,7 +197,7 @@ export const hover = definePageTool({ await handle.asLocator().hover(); }); response.appendResponseLine(`Successfully hovered over the element`); - appendWaitForResult(response, result); + response.attachWaitForResult(result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -330,7 +327,7 @@ export const fill = definePageTool({ ); }); response.appendResponseLine(`Successfully filled out the element`); - appendWaitForResult(response, result); + response.attachWaitForResult(result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -362,7 +359,7 @@ export const typeText = definePageTool({ response.appendResponseLine( `Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`, ); - appendWaitForResult(response, result); + response.attachWaitForResult(result); }, }); @@ -391,7 +388,7 @@ export const drag = definePageTool({ await toHandle.drop(fromHandle); }); response.appendResponseLine(`Successfully dragged an element`); - appendWaitForResult(response, result); + response.attachWaitForResult(result); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -440,7 +437,7 @@ export const fillForm = definePageTool({ }); } response.appendResponseLine(`Successfully filled out the form`); - appendWaitForResult(response, lastResult); + response.attachWaitForResult(lastResult); if (request.params.includeSnapshot) { response.includeSnapshot(); } @@ -466,7 +463,7 @@ export const uploadFile = definePageTool({ blockedByDialog: true, handler: async (request, response, context) => { const {uid, filePath} = request.params; - context.validatePath(filePath); + await context.validatePath(filePath); const handle = (await request.page.getElementByUid( uid, )) as ElementHandle; @@ -533,7 +530,7 @@ export const pressKey = definePageTool({ response.appendResponseLine( `Successfully pressed key: ${request.params.key}`, ); - appendWaitForResult(response, result); + response.attachWaitForResult(result); if (request.params.includeSnapshot) { response.includeSnapshot(); } diff --git a/src/tools/lighthouse.ts b/src/tools/lighthouse.ts index 84aacc7..da0a726 100644 --- a/src/tools/lighthouse.ts +++ b/src/tools/lighthouse.ts @@ -59,7 +59,7 @@ export const lighthouseAudit = definePageTool({ outputDirPath, } = request.params; - context.validatePath(outputDirPath); + await context.validatePath(outputDirPath); const flags: Flags = { onlyCategories: categories, diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 5e8525b..c2dc409 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -10,8 +10,8 @@ import {ensureExtension} from '../utils/files.js'; import {ToolCategory} from './categories.js'; import {definePageTool, defineTool} from './ToolDefinition.js'; -export const takeMemorySnapshot = definePageTool({ - name: 'take_memory_snapshot', +export const takeHeapSnapshot = definePageTool({ + name: 'take_heapsnapshot', description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`, annotations: { category: ToolCategory.MEMORY, @@ -25,7 +25,7 @@ export const takeMemorySnapshot = definePageTool({ blockedByDialog: true, handler: async (request, response, context) => { const page = request.page; - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); await page.pptrPage.captureHeapSnapshot({ path: ensureExtension(request.params.filePath, '.heapsnapshot'), @@ -37,8 +37,8 @@ export const takeMemorySnapshot = definePageTool({ }, }); -export const exploreMemorySnapshot = defineTool({ - name: 'load_memory_snapshot', +export const getHeapSnapshotSummary = defineTool({ + name: 'get_heapsnapshot_summary', description: 'Loads a memory heapsnapshot and returns snapshot summary stats.', annotations: { @@ -51,7 +51,7 @@ export const exploreMemorySnapshot = defineTool({ }, blockedByDialog: false, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); const stats = await context.getHeapSnapshotStats(request.params.filePath); const staticData = await context.getHeapSnapshotStaticData( request.params.filePath, @@ -61,8 +61,8 @@ export const exploreMemorySnapshot = defineTool({ }, }); -export const getMemorySnapshotDetails = defineTool({ - name: 'get_memory_snapshot_details', +export const getHeapSnapshotDetails = defineTool({ + name: 'get_heapsnapshot_details', description: 'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates.', annotations: { @@ -83,7 +83,7 @@ export const getMemorySnapshotDetails = defineTool({ }, blockedByDialog: false, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); const aggregates = await context.getHeapSnapshotAggregates( request.params.filePath, ); @@ -95,10 +95,10 @@ export const getMemorySnapshotDetails = defineTool({ }, }); -export const getNodesByClass = defineTool({ - name: 'get_nodes_by_class', +export const getHeapSnapshotClassNodes = defineTool({ + name: 'get_heapsnapshot_class_nodes', description: - 'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs.', + 'Loads a memory heapsnapshot and returns instances of a specific class with their IDs.', annotations: { category: ToolCategory.MEMORY, readOnlyHint: true, @@ -106,20 +106,16 @@ export const getNodesByClass = defineTool({ }, schema: { filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), - uid: zod - .number() - .describe( - 'The unique UID for the class, obtained from aggregates listing.', - ), + id: zod.number().describe('The ID for the class, obtained from details.'), pageIdx: zod.number().optional().describe('The page index for pagination.'), pageSize: zod.number().optional().describe('The page size for pagination.'), }, blockedByDialog: false, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); - const nodes = await context.getHeapSnapshotNodesByUid( + await context.validatePath(request.params.filePath); + const nodes = await context.getHeapSnapshotNodesById( request.params.filePath, - request.params.uid, + request.params.id, ); response.setHeapSnapshotNodes(nodes, { @@ -128,3 +124,34 @@ export const getNodesByClass = defineTool({ }); }, }); + +export const getHeapSnapshotRetainers = defineTool({ + name: 'get_heapsnapshot_retainers', + description: + 'Loads a memory heapsnapshot and returns retainers for a specific node ID.', + annotations: { + category: ToolCategory.MEMORY, + readOnlyHint: true, + conditions: ['experimentalMemory'], + }, + blockedByDialog: false, + schema: { + filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), + nodeId: zod.number().describe('The node ID to get retainers for.'), + pageIdx: zod.number().optional().describe('The page index for pagination.'), + pageSize: zod.number().optional().describe('The page size for pagination.'), + }, + handler: async (request, response, context) => { + await context.validatePath(request.params.filePath); + + const retainers = await context.getHeapSnapshotRetainers( + request.params.filePath, + request.params.nodeId, + ); + + response.setHeapSnapshotNodes(retainers, { + pageIdx: request.params.pageIdx, + pageSize: request.params.pageSize, + }); + }, +}); diff --git a/src/tools/network.ts b/src/tools/network.ts index ca17d47..1df56a8 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -116,8 +116,8 @@ export const getNetworkRequest = definePageTool({ }, blockedByDialog: true, handler: async (request, response, context) => { - context.validatePath(request.params.requestFilePath); - context.validatePath(request.params.responseFilePath); + await context.validatePath(request.params.requestFilePath); + await context.validatePath(request.params.responseFilePath); if (request.params.reqid) { response.attachNetworkRequest(request.params.reqid, { requestFilePath: request.params.requestFilePath, diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 39f9b31..4ea5f20 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -8,7 +8,6 @@ import zlib from 'node:zlib'; 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, @@ -16,7 +15,7 @@ import { } from '../trace-processing/parse.js'; import {ToolCategory} from './categories.js'; -import type {Context, Response} from './ToolDefinition.js'; +import type {Context, Response, ContextPage} from './ToolDefinition.js'; import {definePageTool} from './ToolDefinition.js'; const filePathSchema = zod @@ -50,7 +49,7 @@ export const startTrace = definePageTool({ }, blockedByDialog: true, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); if (context.isRunningPerformanceTrace()) { 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.', @@ -103,7 +102,7 @@ export const startTrace = definePageTool({ if (request.params.autoStop) { await new Promise(resolve => setTimeout(resolve, 5_000)); await stopTracingAndAppendOutput( - page.pptrPage, + page, response, context, request.params.filePath, @@ -129,13 +128,13 @@ export const stopTrace = definePageTool({ }, blockedByDialog: true, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); if (!context.isRunningPerformanceTrace()) { return; } const page = request.page; await stopTracingAndAppendOutput( - page.pptrPage, + page, response, context, request.params.filePath, @@ -182,13 +181,13 @@ export const analyzeInsight = definePageTool({ }); async function stopTracingAndAppendOutput( - page: Page, + page: ContextPage, response: Response, context: Context, filePath?: string, ): Promise { try { - const traceEventsBuffer = await page.tracing.stop(); + const traceEventsBuffer = await page.pptrPage.tracing.stop(); if (filePath && traceEventsBuffer) { let dataToWrite: Uint8Array = traceEventsBuffer; if (filePath.endsWith('.gz')) { @@ -211,7 +210,10 @@ async function stopTracingAndAppendOutput( `The raw trace data was saved to ${file.filename}.`, ); } - const result = await parseRawTraceBuffer(traceEventsBuffer); + const result = await parseRawTraceBuffer(traceEventsBuffer, { + cpuThrottling: page.cpuThrottlingRate, + networkThrottling: page.networkConditions ?? undefined, + }); response.appendResponseLine('The performance trace has been stopped.'); if (traceResultIsSuccess(result)) { if (context.isCruxEnabled()) { @@ -232,7 +234,7 @@ async function stopTracingAndAppendOutput( /** We tell CrUXManager to fetch data so it's available when DevTools.PerformanceTraceFormatter is invoked */ async function populateCruxData(result: TraceResult): Promise { logger('populateCruxData called'); - const cruxManager = DevTools.CrUXManager.instance(); + const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); // go/jtfbx. Yes, we're aware this API key is public. ;) cruxManager.setEndpointForTesting( 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk', diff --git a/src/tools/screencast.ts b/src/tools/screencast.ts index bb0583c..11af9df 100644 --- a/src/tools/screencast.ts +++ b/src/tools/screencast.ts @@ -40,7 +40,7 @@ export const startScreencast = definePageTool(args => ({ }, blockedByDialog: false, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); if (context.getScreenRecorder() !== null) { response.appendResponseLine( 'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.', diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index d9476aa..746a6c1 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -52,7 +52,7 @@ export const screenshot = definePageTool({ }, blockedByDialog: true, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); if (request.params.uid && request.params.fullPage) { throw new Error('Providing both "uid" and "fullPage" is not allowed.'); } diff --git a/src/tools/script.ts b/src/tools/script.ts index 7d27be5..037200d 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -7,7 +7,6 @@ import {zod} from '../third_party/index.js'; import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; import type {ExtensionServiceWorker} from '../types.js'; -import {appendWaitForResult} from '../WaitForHelper.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -25,6 +24,7 @@ so returned values have to be JSON-serializable.`, readOnlyHint: false, }, schema: { + ...(cliArgs?.experimentalPageIdRouting ? pageIdSchema : {}), function: zod.string().describe( `A JavaScript function declaration to be executed by the tool in the currently selected page. Example without arguments: \`() => { @@ -47,13 +47,18 @@ Example with arguments: \`(el) => { ) .optional() .describe(`An optional list of arguments to pass to the function.`), + filePath: zod + .string() + .optional() + .describe( + 'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.', + ), dialogAction: zod .string() .optional() .describe( 'Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.', ), - ...(cliArgs?.experimentalPageIdRouting ? pageIdSchema : {}), ...(cliArgs?.categoryExtensions ? { serviceWorkerId: zod @@ -73,8 +78,11 @@ Example with arguments: \`(el) => { function: fnString, pageId, dialogAction, + filePath, } = request.params; + await context.validatePath(filePath); + if (cliArgs?.categoryExtensions && serviceWorkerId) { if (uidArgs && uidArgs.length > 0) { throw new Error( @@ -90,11 +98,14 @@ Example with arguments: \`(el) => { .getSelectedMcpPage() .waitForEventsAfterAction( async () => { - await performEvaluation(worker, fnString, [], response); + await performEvaluation(worker, fnString, [], response, { + filePath, + context, + }); }, {handleDialog: dialogAction ?? 'accept'}, ); - appendWaitForResult(response, result); + response.attachWaitForResult(result); return; } @@ -116,11 +127,14 @@ Example with arguments: \`(el) => { const result = await mcpPage.waitForEventsAfterAction( async () => { - await performEvaluation(evaluatable, fnString, args, response); + await performEvaluation(evaluatable, fnString, args, response, { + filePath, + context, + }); }, {handleDialog: dialogAction ?? 'accept'}, ); - appendWaitForResult(response, result); + response.attachWaitForResult(result); } finally { void Promise.allSettled(args.map(arg => arg.dispose())); } @@ -133,6 +147,7 @@ const performEvaluation = async ( fnString: string, args: Array>, response: Response, + options?: {filePath: string; context: Context}, ) => { const fn = await evaluatable.evaluateHandle(`(${fnString})`); try { @@ -144,10 +159,22 @@ const performEvaluation = async ( fn, ...args, ); - response.appendResponseLine('Script ran on page and returned:'); - response.appendResponseLine('```json'); - response.appendResponseLine(`${result}`); - response.appendResponseLine('```'); + if (options?.filePath) { + const data = new TextEncoder().encode(result ?? 'undefined'); + const {filename} = await options.context.saveFile( + data, + options.filePath, + '.json', + ); + response.appendResponseLine( + `Script ran on page. Output saved to ${filename}.`, + ); + } else { + response.appendResponseLine('Script ran on page and returned:'); + response.appendResponseLine('```json'); + response.appendResponseLine(`${result}`); + response.appendResponseLine('```'); + } } finally { void fn.dispose(); } diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 93f6937..8c0c90e 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -35,7 +35,7 @@ in the DevTools Elements panel (if any).`, }, blockedByDialog: true, handler: async (request, response, context) => { - context.validatePath(request.params.filePath); + await context.validatePath(request.params.filePath); response.includeSnapshot({ verbose: request.params.verbose ?? false, filePath: request.params.filePath, diff --git a/src/trace-processing/parse.ts b/src/trace-processing/parse.ts index 7b152d8..277f410 100644 --- a/src/trace-processing/parse.ts +++ b/src/trace-processing/parse.ts @@ -26,6 +26,10 @@ export interface TraceParseError { export async function parseRawTraceBuffer( buffer: Uint8Array | undefined, + metadata?: { + cpuThrottling?: number; + networkThrottling?: string; + }, ): Promise { engine.resetProcessor(); if (!buffer) { @@ -47,7 +51,7 @@ export async function parseRawTraceBuffer( | DevTools.TraceEngine.Types.Events.Event[]; const events = Array.isArray(data) ? data : data.traceEvents; - await engine.parse(events); + await engine.parse(events, {metadata}); const parsedTrace = engine.parsedTrace(); if (!parsedTrace) { return { @@ -76,9 +80,12 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription} ${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`; -export function getTraceSummary(result: TraceResult): string { +export function getTraceSummary( + result: TraceResult, + deviceScope?: DevTools.CrUXManager.DeviceScope | null, +): string { const focus = DevTools.AgentFocus.fromParsedTrace(result.parsedTrace); - const formatter = new DevTools.PerformanceTraceFormatter(focus); + const formatter = new DevTools.PerformanceTraceFormatter(focus, deviceScope); const summaryText = formatter.formatTraceSummary(); return `## Summary of Performance trace findings: ${summaryText} @@ -95,6 +102,7 @@ export function getInsightOutput( result: TraceResult, insightSetId: string, insightName: InsightName, + deviceScope?: DevTools.CrUXManager.DeviceScope | null, ): InsightOutput { if (!result.insights) { return { @@ -121,6 +129,7 @@ export function getInsightOutput( const formatter = new DevTools.PerformanceInsightFormatter( DevTools.AgentFocus.fromParsedTrace(result.parsedTrace), matchingInsight, + deviceScope, ); return {output: formatter.formatInsight()}; } diff --git a/src/types.ts b/src/types.ts index 13e64f5..2107b55 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,4 +31,5 @@ export interface EmulationSettings { userAgent?: string; colorScheme?: 'dark' | 'light'; viewport?: Viewport; + extraHttpHeaders?: Record; } diff --git a/src/utils/files.ts b/src/utils/files.ts index 00083df..66b3d5d 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -22,3 +22,51 @@ export function ensureExtension( const ext = path.extname(filepath); return filepath.slice(0, filepath.length - ext.length) + extension; } + +export async function resolveCanonicalPath(filePath: string): Promise { + const absolutePath = path.resolve(filePath); + try { + // Get the true canonical path, resolving all symlinks. + return await fs.realpath(absolutePath); + } catch (err) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + // Find the nearest existing ancestor directory on the filesystem. + let current = absolutePath; + const missingSegments: string[] = []; + while (true) { + const parent = path.dirname(current); + if (parent === current) { + // Reached root directory but still couldn't resolve anything. + throw err; + } + try { + const canonicalParent = await fs.realpath(parent); + return path.join( + canonicalParent, + path.basename(current), + ...missingSegments, + ); + } catch (parentErr) { + if ( + parentErr && + typeof parentErr === 'object' && + 'code' in parentErr && + parentErr.code === 'ENOENT' + ) { + missingSegments.unshift(path.basename(current)); + current = parent; + } else { + throw parentErr; + } + } + } + } else { + throw err; + } + } +} diff --git a/tests/DevtoolsUtils.test.ts b/tests/DevtoolsUtils.test.ts index 8f9aa37..d51e6f0 100644 --- a/tests/DevtoolsUtils.test.ts +++ b/tests/DevtoolsUtils.test.ts @@ -13,14 +13,18 @@ import {UniverseManager} from '../src/DevtoolsUtils.js'; import {DevTools} from '../src/third_party/index.js'; import type {Browser, Target} from '../src/third_party/index.js'; +import {serverHooks} from './server.js'; import { getMockBrowser, getMockPage, + html, mockListener, withBrowser, } from './utils.js'; describe('UniverseManager', () => { + const server = serverHooks(); + afterEach(() => { sinon.restore(); }); @@ -86,4 +90,30 @@ describe('UniverseManager', () => { sinon.assert.notCalled(pausedSpy); }); }); + + it('disables network domain', async () => { + server.addHtmlRoute('/test', html`
Test
`); + + await withBrowser(async (browser, page) => { + const manager = new UniverseManager(browser); + await manager.init([page]); + const targetUniverse = manager.get(page); + assert.ok(targetUniverse); + + const networkManager = targetUniverse.target.model( + DevTools.NetworkManager.NetworkManager, + ); + assert.ok(networkManager); + + const requestStartedSpy = sinon.stub(); + networkManager.addEventListener( + DevTools.NetworkManager.Events.RequestStarted, + requestStartedSpy, + ); + + await page.goto(server.getRoute('/test')); + + sinon.assert.notCalled(requestStartedSpy); + }); + }); }); diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 487e595..52e2293 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {afterEach, describe, it} from 'node:test'; @@ -147,7 +148,7 @@ describe('McpContext', () => { response.setIncludeNetworkRequests(true); const result = await response.handle('test', context); - t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); + t.assert.snapshot(JSON.stringify(result.structuredContent, null, 2)); }); }); @@ -164,7 +165,7 @@ describe('McpContext', () => { response.attachNetworkRequest(456); const result = await response.handle('test', context); - t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); + t.assert.snapshot(JSON.stringify(result.structuredContent, null, 2)); }); }); @@ -211,7 +212,7 @@ describe('McpContext', () => { }); const result = await response.handle('test', context); - t.assert.snapshot?.(JSON.stringify(result.structuredContent, null, 2)); + t.assert.snapshot(JSON.stringify(result.structuredContent, null, 2)); fromStub.restore(); }); @@ -236,24 +237,51 @@ describe('McpContext', () => { it('validatePath allows paths within roots', async () => { await withMcpContext(async (_response, context) => { const workspacePath = path.resolve(os.homedir(), 'workspace-test'); - const roots = [ - {uri: pathToFileURL(workspacePath).href, name: 'workspace'}, - ]; - context.setRoots(roots); - // Valid path within root - context.validatePath(path.join(workspacePath, 'test.txt')); - context.validatePath(workspacePath); + await fs.mkdir(workspacePath, {recursive: true}); + try { + const roots = [ + {uri: pathToFileURL(workspacePath).href, name: 'workspace'}, + ]; + context.setRoots(roots); + // Valid path within root + await context.validatePath(path.join(workspacePath, 'test.txt')); + await context.validatePath(workspacePath); + + // Invalid path outside root and outside temp dir + const outsidePath = path.resolve(os.homedir(), 'outside-test.txt'); + await assert.rejects( + context.validatePath(outsidePath), + /Access denied/, + ); + } finally { + await fs.rm(workspacePath, {recursive: true, force: true}); + } + }); + }); - // Invalid path outside root and outside temp dir - const outsidePath = path.resolve(os.homedir(), 'outside-test.txt'); - assert.throws(() => context.validatePath(outsidePath), /Access denied/); + it('validatePath allows non-existent nested paths within roots', async () => { + await withMcpContext(async (_response, context) => { + const workspacePath = path.resolve(os.homedir(), 'workspace-test-nested'); + await fs.mkdir(workspacePath, {recursive: true}); + try { + const roots = [ + {uri: pathToFileURL(workspacePath).href, name: 'workspace'}, + ]; + context.setRoots(roots); + // Valid path within root with non-existent intermediate directories + await context.validatePath( + path.join(workspacePath, 'dir1', 'dir2', 'test.txt'), + ); + } finally { + await fs.rm(workspacePath, {recursive: true, force: true}); + } }); }); it('validatePath allows all paths if roots are undefined (legacy)', async () => { await withMcpContext(async (_response, context) => { context.setRoots(undefined); - context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')); + await context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')); }); }); @@ -261,11 +289,11 @@ describe('McpContext', () => { await withMcpContext(async (_response, context) => { context.setRoots([]); // Should allow temp dir - context.validatePath(path.join(os.tmpdir(), 'test.txt')); + await context.validatePath(path.join(os.tmpdir(), 'test.txt')); // Should deny outside temp dir - assert.throws( - () => context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')), + await assert.rejects( + context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')), /Access denied/, ); }); diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 9b5597d..b98a0ae 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -1286,7 +1286,7 @@ exports[`webmcp > includes webmcp tools in list_pages response 1`] = ` ## Pages 1: about:blank [selected] ## WebMCP tools -name="test_tool", description="A test tool", inputSchema={}, annotations=undefined +name="test_tool", description="A test tool", inputSchema={"type":"object","properties":{},"required":[]}, annotations=undefined `; exports[`webmcp > includes webmcp tools in list_pages response 2`] = ` @@ -1302,7 +1302,11 @@ exports[`webmcp > includes webmcp tools in list_pages response 2`] = ` { "name": "test_tool", "description": "A test tool", - "inputSchema": {} + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } } ] } @@ -1313,7 +1317,7 @@ Successfully navigated to about:blank. ## Pages 1: about:blank [selected] ## WebMCP tools -name="test_tool", description="A test tool", inputSchema={}, annotations=undefined +name="test_tool", description="A test tool", inputSchema={"type":"object","properties":{},"required":[]}, annotations=undefined `; exports[`webmcp > includes webmcp tools in navigate_page response 2`] = ` @@ -1330,7 +1334,11 @@ exports[`webmcp > includes webmcp tools in navigate_page response 2`] = ` { "name": "test_tool", "description": "A test tool", - "inputSchema": {} + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } } ] } @@ -1340,7 +1348,7 @@ exports[`webmcp > includes webmcp tools in select_page response 1`] = ` ## Pages 1: about:blank [selected] ## WebMCP tools -name="test_tool", description="A test tool", inputSchema={}, annotations=undefined +name="test_tool", description="A test tool", inputSchema={"type":"object","properties":{},"required":[]}, annotations=undefined `; exports[`webmcp > includes webmcp tools in select_page response 2`] = ` @@ -1356,7 +1364,11 @@ exports[`webmcp > includes webmcp tools in select_page response 2`] = ` { "name": "test_tool", "description": "A test tool", - "inputSchema": {} + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } } ] } diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 76f6a11..53957bb 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -58,8 +58,8 @@ describe('McpResponse', () => { context, ); assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -74,8 +74,8 @@ describe('McpResponse', () => { context, ); assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -89,8 +89,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -112,8 +112,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -136,8 +136,8 @@ describe('McpResponse', () => { context, ); assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -155,8 +155,8 @@ describe('McpResponse', () => { context, ); assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot(JSON.stringify(structuredContent, null, 2)); }); }); @@ -175,10 +175,8 @@ describe('McpResponse', () => { context, ); assert.equal(content[0].type, 'text'); - t.assert.snapshot?.( - stabilizeResponseOutput(getTextContent(content[0])), - ); - t.assert.snapshot?.( + t.assert.snapshot(stabilizeResponseOutput(getTextContent(content[0]))); + t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, @@ -187,7 +185,7 @@ describe('McpResponse', () => { ); }); const content = await readFile(filePath, 'utf-8'); - t.assert.snapshot?.(stabilizeResponseOutput(content)); + t.assert.snapshot(stabilizeResponseOutput(content)); } finally { await rm(filePath, {force: true}); } @@ -306,8 +304,8 @@ describe('McpResponse', () => { context, ); assert.equal(content[0].type, 'text'); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -320,8 +318,8 @@ describe('McpResponse', () => { context, ); await context.emulate({}); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -333,11 +331,11 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); + t.assert.snapshot(getTextContent(content[0])); assert.equal(content[1].type, 'image'); assert.strictEqual(getImageContent(content[1]).data, 'imageBase64'); assert.strictEqual(getImageContent(content[1]).mimeType, 'image/png'); - t.assert.snapshot?.( + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -350,8 +348,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -364,8 +362,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -380,8 +378,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -394,8 +392,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -408,8 +406,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -432,8 +430,8 @@ describe('McpResponse', () => { context, ); await page.getDialog()?.dismiss(); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -456,8 +454,8 @@ describe('McpResponse', () => { context, ); await page.getDialog()?.dismiss(); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -473,8 +471,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -490,8 +488,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -528,8 +526,8 @@ describe('McpResponse', () => { context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -550,8 +548,8 @@ describe('McpResponse', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -575,8 +573,8 @@ describe('McpResponse', () => { context, ); assert.ok(getTextContent(content[0])); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -590,8 +588,8 @@ describe('McpResponse', () => { context, ); assert.ok(getTextContent(content[0])); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -616,7 +614,7 @@ describe('McpResponse', () => { ); const text = getTextContent(content[0]); assert.ok(text.includes('')); - t.assert.snapshot?.( + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -662,8 +660,8 @@ describe('McpResponse network request filtering', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -685,8 +683,8 @@ describe('McpResponse network request filtering', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -708,8 +706,8 @@ describe('McpResponse network request filtering', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -732,8 +730,8 @@ describe('McpResponse network request filtering', () => { context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -757,8 +755,8 @@ describe('McpResponse network request filtering', () => { 'test', context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -779,7 +777,7 @@ describe('McpResponse network pagination', () => { 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?.( + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -802,7 +800,7 @@ describe('McpResponse network pagination', () => { 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?.( + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -826,7 +824,7 @@ describe('McpResponse network pagination', () => { 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?.( + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -849,7 +847,7 @@ describe('McpResponse network pagination', () => { 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?.( + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -870,15 +868,15 @@ describe('McpResponse network pagination', () => { context, ); - t.assert.snapshot?.(getTextContent(content[0])); + t.assert.snapshot(getTextContent(content[0])); const typedStructuredContent = structuredContent as { traceSummary?: string; traceInsights?: unknown[]; }; - t.assert.snapshot?.( + t.assert.snapshot( JSON.stringify(typedStructuredContent.traceSummary, null, 2), ); - t.assert.snapshot?.( + t.assert.snapshot( JSON.stringify(typedStructuredContent.traceInsights, null, 2), ); }); @@ -904,8 +902,8 @@ describe('McpResponse network pagination', () => { context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, @@ -933,8 +931,8 @@ describe('McpResponse network pagination', () => { context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, @@ -991,8 +989,8 @@ describe('extensions', () => { context, ); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot(JSON.stringify(structuredContent, null, 2)); }); }); }); @@ -1034,8 +1032,8 @@ describe('lighthouse', () => { assert.ok(text.includes('- /tmp/report.json')); assert.ok(text.includes('- /tmp/report.html')); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); @@ -1096,12 +1094,12 @@ describe('third-party developer tools', () => { context, ); const responseText = getTextContent(content[0]); - t.assert.snapshot?.(responseText); + t.assert.snapshot(responseText); assert.ok( responseText.includes('inputSchema={"type":"object"'), 'Response should include inputSchema', ); - t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); + t.assert.snapshot(JSON.stringify(structuredContent, null, 2)); }, undefined, {categoryExperimentalThirdParty: true} as ParsedArguments, @@ -1478,20 +1476,25 @@ describe('webmcp', () => { await handlerAction(response, context); const page = context.getSelectedMcpPage().pptrPage; + const {resolve, promise} = Promise.withResolvers(); + page.webmcp.once('toolsadded', () => { + resolve(undefined); + }); await page.setContent( html`
`, ); + await promise; const {content, structuredContent} = await response.handle( toolName, context, ); assert.ok(getTextContent(content[0])); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, @@ -1555,8 +1558,8 @@ describe('webmcp', () => { context, ); assert.ok(getTextContent(content[0])); - t.assert.snapshot?.(getTextContent(content[0])); - t.assert.snapshot?.( + t.assert.snapshot(getTextContent(content[0])); + t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, diff --git a/tests/ToolHandler.test.ts b/tests/ToolHandler.test.ts index db790c6..db46302 100644 --- a/tests/ToolHandler.test.ts +++ b/tests/ToolHandler.test.ts @@ -13,6 +13,7 @@ import {parseArguments} from '../src/bin/opera-devtools-mcp-cli-options.js'; import {McpContext} from '../src/McpContext.js'; import {McpPage} from '../src/McpPage.js'; import {Mutex} from '../src/Mutex.js'; +import {zod} from '../src/third_party/index.js'; import {ToolHandler} from '../src/ToolHandler.js'; import {ToolCategory} from '../src/tools/categories.js'; import type { @@ -106,6 +107,55 @@ describe('ToolHandler', () => { assert.strictEqual(result.isError, undefined); }); + it('reports unknown registered tool arguments clearly', async () => { + let handlerCalled = false; + const tool: ToolDefinition = { + name: 'lenient_tool', + description: 'A tool with a required argument', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: true, + }, + schema: { + url: zod.string(), + }, + blockedByDialog: false, + handler: async () => { + handlerCalled = true; + }, + }; + + const mockContext = sinon.createStubInstance(McpContext); + mockContext.detectOpenDevToolsWindows.resolves(); + + const toolMutex = new Mutex(); + const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], { + CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', + }); + + const toolHandler = new ToolHandler( + tool, + serverArgs, + async () => mockContext, + toolMutex, + ); + + const params = {url: 'https://example.com', description: 'open the page'}; + assert.strictEqual( + toolHandler.registeredInputSchema.safeParse(params).success, + true, + ); + + const result = await toolHandler.handle(params); + + assert.strictEqual(result.isError, true); + assert.match( + result.content[0].type === 'text' ? result.content[0].text : '', + /Unknown argument for tool "lenient_tool": "description"\. Expected arguments: "url"\./, + ); + assert.strictEqual(handlerCalled, false); + }); + it('sets shouldRegister to false and returns disabled reason when category is disabled', async () => { let handlerCalled = false; const tool: ToolDefinition = { diff --git a/tests/browser.test.ts b/tests/browser.test.ts index b0835bf..85e9c59 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -25,7 +25,7 @@ describe('browser', () => { headless: true, isolated: false, userDataDir: folderPath, - executablePath: executablePath(), + executablePath: await executablePath(), devtools: false, }); try { @@ -34,7 +34,7 @@ describe('browser', () => { headless: true, isolated: false, userDataDir: folderPath, - executablePath: executablePath(), + executablePath: await executablePath(), devtools: false, }); await browser2.close(); @@ -57,7 +57,7 @@ describe('browser', () => { headless: true, isolated: false, userDataDir: folderPath, - executablePath: executablePath(), + executablePath: await executablePath(), viewport: { width: 1501, height: 801, @@ -84,7 +84,7 @@ describe('browser', () => { headless: true, isolated: false, userDataDir: folderPath, - executablePath: executablePath(), + executablePath: await executablePath(), devtools: false, chromeArgs: ['--remote-debugging-port=0'], }); diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts index 7db5cfd..f263d9b 100644 --- a/tests/daemon/client.test.ts +++ b/tests/daemon/client.test.ts @@ -6,6 +6,8 @@ import assert from 'node:assert'; import crypto from 'node:crypto'; +import {existsSync, rmSync} from 'node:fs'; +import {dirname} from 'node:path'; import {describe, it, afterEach, beforeEach} from 'node:test'; import { @@ -127,6 +129,43 @@ describe('daemon client', () => { assert.ok(response.includes('.png')); }); + it('includes saved image file paths in structured JSON responses', async () => { + const imageContentResponse = { + content: [ + { + type: 'text' as const, + text: 'Took a screenshot.', + }, + { + type: 'image' as const, + data: Buffer.from('image data').toString('base64'), + mimeType: 'image/png', + }, + ], + structuredContent: { + message: 'Took a screenshot.', + }, + }; + let filePath: string | undefined; + try { + const response = await handleResponse(imageContentResponse, 'json'); + const parsed = JSON.parse(response) as { + message: string; + images: Array<{filePath: string; mimeType: string}>; + }; + assert.strictEqual(parsed.message, 'Took a screenshot.'); + assert.strictEqual(parsed.images.length, 1); + assert.strictEqual(parsed.images[0].mimeType, 'image/png'); + filePath = parsed.images[0].filePath; + assert.ok(filePath.endsWith('.png')); + assert.ok(existsSync(filePath)); + } finally { + if (filePath) { + rmSync(dirname(filePath), {recursive: true, force: true}); + } + } + }); + it('uses the webp extension for WebP images', async () => { const webpContentResponse = { content: [ diff --git a/tests/daemon/symlink.test.ts b/tests/daemon/symlink.test.ts new file mode 100644 index 0000000..3726464 --- /dev/null +++ b/tests/daemon/symlink.test.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {spawn} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import {describe, it, afterEach, beforeEach} from 'node:test'; + +import { + DAEMON_SCRIPT_PATH, + getPidFilePath, + IS_WINDOWS, +} from '../../src/daemon/utils.js'; + +describe('daemon security checks', () => { + let sessionId: string; + + beforeEach(() => { + sessionId = crypto.randomUUID(); + }); + + afterEach(() => { + const pidFilePath = getPidFilePath(sessionId); + const pidDir = path.dirname(pidFilePath); + try { + fs.unlinkSync(pidFilePath); + } catch { + // ignore + } + try { + fs.rmdirSync(pidDir); + } catch { + // ignore + } + }); + + it('should not follow symlinks and fail to write PID file', async () => { + if (IS_WINDOWS) { + return; + } + const pidFilePath = getPidFilePath(sessionId); + const pidDir = path.dirname(pidFilePath); + + // Ensure directory exists with safe permissions + fs.mkdirSync(pidDir, {recursive: true}); + fs.chmodSync(pidDir, 0o700); + + // Create a target file that we do NOT want to be overwritten + const targetPath = path.join(pidDir, 'target_file.txt'); + fs.writeFileSync(targetPath, 'original content', 'utf-8'); + + // Create a symlink at pidFilePath pointing to targetPath + fs.symlinkSync(targetPath, pidFilePath); + + // Try to spawn the daemon + const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH], { + env: {...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId}, + }); + + const exitCode = await new Promise(resolve => { + child.on('exit', code => { + resolve(code); + }); + }); + + // Daemon should have exited with error code 1 + assert.strictEqual(exitCode, 1); + + // Target file content should remain unchanged ("original content") + const content = fs.readFileSync(targetPath, 'utf-8'); + assert.strictEqual(content, 'original content'); + + // Clean up target file and symlink + try { + fs.unlinkSync(pidFilePath); + } catch { + // ignore + } + try { + fs.unlinkSync(targetPath); + } catch { + // ignore + } + }); + + it('should fail if directory has insecure permissions (group/world writable)', async () => { + if (IS_WINDOWS) { + return; + } + const pidFilePath = getPidFilePath(sessionId); + const pidDir = path.dirname(pidFilePath); + + // Ensure directory exists + fs.mkdirSync(pidDir, {recursive: true}); + + // Change permissions to 0o777 (group and world writable) + fs.chmodSync(pidDir, 0o777); + + // Try to spawn the daemon + const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH], { + env: {...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId}, + }); + + const exitCode = await new Promise(resolve => { + child.on('exit', code => { + resolve(code); + }); + }); + + // Daemon should have exited with error code 1 + assert.strictEqual(exitCode, 1); + + // Restore permissions so cleanup can run successfully + try { + fs.chmodSync(pidDir, 0o700); + } catch { + // ignore + } + }); +}); diff --git a/tests/e2e/opera-devtools-commands.test.ts b/tests/e2e/opera-devtools-commands.test.ts index 752c70d..b2e3cde 100644 --- a/tests/e2e/opera-devtools-commands.test.ts +++ b/tests/e2e/opera-devtools-commands.test.ts @@ -92,4 +92,41 @@ describe('opera-devtools', () => { 'error output is unexpected: ' + result.stdout + result.stderr, ); }); + + it('can record a performance trace', async () => { + const startResult = await runCli( + ['start', '--performanceCrux=false'], + sessionId, + ); + assert.strictEqual( + startResult.status, + 0, + `start command failed: ${startResult.stderr}`, + ); + + const emulateResult = await runCli( + ['emulate', '--cpuThrottlingRate', '2'], + sessionId, + ); + assert.strictEqual( + emulateResult.status, + 0, + `emulate command failed: ${emulateResult.stderr}`, + ); + + const result = await runCli(['performance_start_trace'], sessionId); + assert.strictEqual( + result.status, + 0, + `performance_start_trace command failed: ${result.stderr}`, + ); + assert( + result.stdout.includes('The performance trace has been stopped.'), + 'performance_start_trace output is unexpected: ' + result.stdout, + ); + assert( + result.stdout.includes('CPU throttling: 2x'), + 'performance_start_trace output is unexpected: ' + result.stdout, + ); + }); }); diff --git a/tests/formatters/ConsoleFormatter.test.ts b/tests/formatters/ConsoleFormatter.test.ts index eba9f9d..977492b 100644 --- a/tests/formatters/ConsoleFormatter.test.ts +++ b/tests/formatters/ConsoleFormatter.test.ts @@ -39,11 +39,11 @@ function formatterTestConcise( ) { it(label + ' toString', async t => { const formatter = await setup(t); - t.assert.snapshot?.(formatter.toString()); + t.assert.snapshot(formatter.toString()); }); it(label + ' toJSON', async t => { const formatter = await setup(t); - t.assert.snapshot?.(JSON.stringify(formatter.toJSON(), null, 2)); + t.assert.snapshot(JSON.stringify(formatter.toJSON(), null, 2)); }); } @@ -53,11 +53,11 @@ function formatterTestDetailed( ) { it(label + ' toStringDetailed', async t => { const formatter = await setup(t); - t.assert.snapshot?.(formatter.toStringDetailed()); + t.assert.snapshot(formatter.toStringDetailed()); }); it(label + ' toJSONDetailed', async t => { const formatter = await setup(t); - t.assert.snapshot?.(JSON.stringify(formatter.toJSONDetailed(), null, 2)); + t.assert.snapshot(JSON.stringify(formatter.toJSONDetailed(), null, 2)); }); } diff --git a/tests/formatters/HeapSnapshotFormatter.test.js.snapshot b/tests/formatters/HeapSnapshotFormatter.test.js.snapshot index 9756c8a..4324005 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.js.snapshot +++ b/tests/formatters/HeapSnapshotFormatter.test.js.snapshot @@ -1,5 +1,5 @@ exports[`HeapSnapshotFormatter > toString > formats data as CSV and sorts by retained size 1`] = ` -uid,className,count,selfSize,maxRetainedSize -1,"ObjectA",10,100,1000 -2,"ObjectB",5,50,500 +id,name,count,selfSize,maxRetainedSize +1,ObjectA,10,0.1 kB,1.0 kB +2,ObjectB,5,0.1 kB,0.5 kB `; diff --git a/tests/formatters/HeapSnapshotFormatter.test.ts b/tests/formatters/HeapSnapshotFormatter.test.ts index d1b29c4..11d0e02 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.ts +++ b/tests/formatters/HeapSnapshotFormatter.test.ts @@ -8,10 +8,19 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; import {HeapSnapshotFormatter} from '../../src/formatters/HeapSnapshotFormatter.js'; -import type {DevTools} from '../../src/third_party/index.js'; +import {DevTools} from '../../src/third_party/index.js'; import {stableIdSymbol} from '../../src/utils/id.js'; describe('HeapSnapshotFormatter', () => { + DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({ + create: true, + data: { + navigatorLanguage: 'en-US', + settingLanguage: 'en-US', + lookupClosestDevToolsLocale: l => l, + }, + }); + DevTools.I18n.i18n.registerLocaleDataForTest('en-US', {}); const mockAggregates: Record< string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo @@ -40,7 +49,7 @@ describe('HeapSnapshotFormatter', () => { it('formats data as CSV and sorts by retained size', t => { const formatter = new HeapSnapshotFormatter(mockAggregates); const result = formatter.toString(); - t.assert.snapshot?.(result); + t.assert.snapshot(result); }); }); @@ -50,23 +59,77 @@ describe('HeapSnapshotFormatter', () => { const result = formatter.toJSON(); assert.deepStrictEqual(result, [ { - uid: 1, + id: 1, className: 'ObjectA', count: 10, - selfSize: 100, - retainedSize: 1000, + selfSize: DevTools.I18n.ByteUtilities.formatBytesToKb(100), + retainedSize: DevTools.I18n.ByteUtilities.formatBytesToKb(1000), }, { - uid: 2, + id: 2, className: 'ObjectB', count: 5, - selfSize: 50, - retainedSize: 500, + selfSize: DevTools.I18n.ByteUtilities.formatBytesToKb(50), + retainedSize: DevTools.I18n.ByteUtilities.formatBytesToKb(500), }, ]); }); }); + describe('formatNodes', () => { + it('formats edges correctly', () => { + const mockEdges = [ + { + name: 'edge1', + type: 'property', + edgeIndex: 0, + isAddedNotRemoved: null, + node: { + id: 1, + name: 'NodeA', + distance: 0, + nodeIndex: 0, + retainedSize: 0, + selfSize: 0, + type: 'object', + canBeQueried: false, + detachedDOMTreeNode: false, + ignored: false, + isAddedNotRemoved: null, + }, + }, + { + name: 'edge2', + type: 'element', + edgeIndex: 1, + isAddedNotRemoved: null, + node: { + id: 2, + name: 'NodeB', + distance: 0, + nodeIndex: 0, + retainedSize: 0, + selfSize: 0, + type: 'object', + canBeQueried: false, + detachedDOMTreeNode: false, + ignored: false, + isAddedNotRemoved: null, + }, + }, + ]; + + const result = HeapSnapshotFormatter.formatNodes(mockEdges); + const expected = [ + 'name,type,nodeId,nodeName', + 'edge1,property,1,NodeA', + 'edge2,element,2,NodeB', + ].join('\n'); + + assert.strictEqual(result, expected); + }); + }); + describe('sort', () => { it('sorts aggregates by retained size descending', () => { const unsortedAggregates: Record< diff --git a/tests/formatters/IssueFormatter.test.ts b/tests/formatters/IssueFormatter.test.ts index 80e96e9..605e89d 100644 --- a/tests/formatters/IssueFormatter.test.ts +++ b/tests/formatters/IssueFormatter.test.ts @@ -30,11 +30,11 @@ describe('IssueFormatter', () => { ) { it(label + ' toString', async t => { const formatter = await setup(t); - t.assert.snapshot?.(formatter.toString()); + t.assert.snapshot(formatter.toString()); }); it(label + ' toJSON', async t => { const formatter = await setup(t); - t.assert.snapshot?.(JSON.stringify(formatter.toJSON(), null, 2)); + t.assert.snapshot(JSON.stringify(formatter.toJSON(), null, 2)); }); } @@ -44,11 +44,11 @@ describe('IssueFormatter', () => { ) { it(label + ' toStringDetailed', async t => { const formatter = await setup(t); - t.assert.snapshot?.(formatter.toStringDetailed()); + t.assert.snapshot(formatter.toStringDetailed()); }); it(label + ' toJSONDetailed', async t => { const formatter = await setup(t); - t.assert.snapshot?.(JSON.stringify(formatter.toJSONDetailed(), null, 2)); + t.assert.snapshot(JSON.stringify(formatter.toJSONDetailed(), null, 2)); }); } diff --git a/tests/formatters/NetworkFormatter.test.ts b/tests/formatters/NetworkFormatter.test.ts index 3ff5b5d..e37e5ba 100644 --- a/tests/formatters/NetworkFormatter.test.ts +++ b/tests/formatters/NetworkFormatter.test.ts @@ -305,7 +305,7 @@ describe('NetworkFormatter', () => { redactNetworkHeaders: false, }); const result = formatter.toStringDetailed(); - t.assert.snapshot?.(result); + t.assert.snapshot(result); }); it('shows saved to file message in toStringDetailed', async () => { const request = { diff --git a/tests/formatters/snapshotFormatter.test.ts b/tests/formatters/snapshotFormatter.test.ts index 3938528..3f7483d 100644 --- a/tests/formatters/snapshotFormatter.test.ts +++ b/tests/formatters/snapshotFormatter.test.ts @@ -191,7 +191,7 @@ describe('snapshotFormatter', () => { }); const formatted = formatter.toString(); - t.assert.snapshot?.(formatted); + t.assert.snapshot(formatted); }); it('does not include a note if the snapshot is already verbose', t => { @@ -225,7 +225,7 @@ describe('snapshotFormatter', () => { }); const formatted = formatter.toString(); - t.assert.snapshot?.(formatted); + t.assert.snapshot(formatted); }); it('formats with DevTools data included into a snapshot', t => { @@ -260,7 +260,7 @@ describe('snapshotFormatter', () => { }); const formatted = formatter.toString(); - t.assert.snapshot?.(formatted); + t.assert.snapshot(formatted); }); it('toJSON returns expected structure', () => { diff --git a/tests/index.test.ts b/tests/index.test.ts index b76258e..796e82e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -37,7 +37,7 @@ describe('e2e', () => { '--headless', '--isolated', '--executable-path', - executablePath(), + await executablePath(), ...extraArgs, ], env: {...process.env, OPERA_DEVTOOLS_NO_USAGE_STATISTICS: 'true'}, @@ -65,7 +65,7 @@ describe('e2e', () => { name: 'list_pages', arguments: {}, }); - t.assert.snapshot?.(JSON.stringify(result.content)); + t.assert.snapshot(JSON.stringify(result.content)); }); }); @@ -79,7 +79,7 @@ describe('e2e', () => { name: 'list_pages', arguments: {}, }); - t.assert.snapshot?.(JSON.stringify(result.content)); + t.assert.snapshot(JSON.stringify(result.content)); }); }); @@ -282,7 +282,7 @@ describe('e2e', () => { it('returns blocked message when dialog is opened during tool execution', async t => { await withClient(async client => { const result = await createNewPageAndTriggerDialog(client); - t.assert.snapshot?.(JSON.stringify(result)); + t.assert.snapshot(JSON.stringify(result)); }); }); @@ -296,7 +296,7 @@ describe('e2e', () => { }, }); - t.assert.snapshot?.(JSON.stringify(result)); + t.assert.snapshot(JSON.stringify(result)); }); }); @@ -310,7 +310,7 @@ describe('e2e', () => { }, }); - t.assert.snapshot?.(JSON.stringify(result)); + t.assert.snapshot(JSON.stringify(result)); }); }); }); diff --git a/tests/roots.test.ts b/tests/roots.test.ts index ee7a066..403e364 100644 --- a/tests/roots.test.ts +++ b/tests/roots.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {describe, it} from 'node:test'; @@ -18,7 +19,7 @@ describe('McpContext Roots', () => { context.setRoots([]); const tmpPath = path.join(os.tmpdir(), 'test-file.txt'); // This should not throw - context.validatePath(tmpPath); + await context.validatePath(tmpPath); }); }); @@ -26,24 +27,31 @@ describe('McpContext Roots', () => { await withMcpContext(async (_response, context) => { const otherRoot = path.resolve( os.tmpdir(), - '..', 'other_workspace_root_for_test', ); - context.setRoots([{uri: pathToFileURL(otherRoot).href, name: 'other'}]); + await fs.mkdir(otherRoot, {recursive: true}); + try { + context.setRoots([{uri: pathToFileURL(otherRoot).href, name: 'other'}]); - const tmpPath = path.join(os.tmpdir(), 'test-file.txt'); - // This should not throw. - context.validatePath(tmpPath); + const tmpPath = path.join(os.tmpdir(), 'test-file.txt'); + // This should not throw. + await context.validatePath(tmpPath); - // Other root should also be allowed. - context.validatePath(path.join(otherRoot, 'file.txt')); + // Other root should also be allowed. + await context.validatePath(path.join(otherRoot, 'file.txt')); - // Outside should still be denied. Use a path that is definitely not a root or temp dir. - const outsidePath = path.resolve( - os.homedir(), - 'a_very_unlikely_path_name_12345', - ); - assert.throws(() => context.validatePath(outsidePath), /Access denied/); + // Outside should still be denied. Use a path that is definitely not a root or temp dir. + const outsidePath = path.resolve( + os.homedir(), + 'a_very_unlikely_path_name_12345', + ); + await assert.rejects( + context.validatePath(outsidePath), + /Access denied/, + ); + } finally { + await fs.rm(otherRoot, {recursive: true, force: true}); + } }); }); }); diff --git a/tests/setup.ts b/tests/setup.ts index d48cc49..b9a54b0 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -9,17 +9,6 @@ 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 diff --git a/tests/shutdown.test.ts b/tests/shutdown.test.ts new file mode 100644 index 0000000..f492f99 --- /dev/null +++ b/tests/shutdown.test.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import type {ChildProcessByStdio} from 'node:child_process'; +import {spawn} from 'node:child_process'; +import type {Readable, Writable} from 'node:stream'; +import {describe, it} from 'node:test'; + +import {executablePath} from 'puppeteer'; + +type Server = ChildProcessByStdio; + +// Once shutdown is signalled, the server should be fully gone within this +// budget. The actual fast path is well under 500ms; the budget is set to be +// generous against CI noise without being so loose that it would hide a hang. +const SHUTDOWN_BUDGET_MS = 3000; +// Outer test timeout. If exit doesn't happen within this, treat as a hang +// (the bug we're guarding against) and SIGKILL the subprocess. +const EXIT_TIMEOUT_MS = 15000; + +async function spawnServer(): Promise { + const child = spawn( + 'node', + [ + 'build/src/bin/chrome-devtools-mcp.js', + '--headless', + '--isolated', + '--executable-path', + await executablePath(), + ], + { + env: { + ...process.env, + CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) as Server; + // Drain stderr to avoid pipe-buffer backpressure stalling the server. + child.stderr.on('data', () => { + // discard + }); + return child; +} + +async function waitForExit( + child: Server, + timeoutMs: number, +): Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + elapsedMs: number; +}> { + const start = Date.now(); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`server did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + child.once('exit', (code, signal) => { + clearTimeout(timer); + resolve({code, signal, elapsedMs: Date.now() - start}); + }); + }); +} + +async function rpc( + child: Server, + msg: {method: string; params?: unknown}, +): Promise { + const id = Math.floor(Math.random() * 1e9); + const payload = JSON.stringify({jsonrpc: '2.0', id, ...msg}) + '\n'; + return await new Promise((resolve, reject) => { + let buf = ''; + const onData = (chunk: Buffer) => { + buf += chunk.toString(); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) { + continue; + } + try { + const parsed = JSON.parse(line) as {id?: number}; + if (parsed.id === id) { + child.stdout.off('data', onData); + resolve(parsed); + return; + } + } catch { + // Not a JSON message; ignore. + } + } + }; + child.stdout.on('data', onData); + const onExit = () => { + child.stdout.off('data', onData); + reject(new Error('server exited before RPC response')); + }; + child.once('exit', onExit); + child.stdin.write(payload); + }); +} + +function notify(child: Server, msg: {method: string; params?: unknown}): void { + child.stdin.write(JSON.stringify({jsonrpc: '2.0', ...msg}) + '\n'); +} + +async function initializeAndLaunchBrowser(child: Server): Promise { + await rpc(child, { + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: {name: 'shutdown-test', version: '0.0.1'}, + }, + }); + notify(child, {method: 'notifications/initialized'}); + // navigate_page forces a real Chrome launch — this is what reproduces + // the hang in #2116. Without an active Chrome subprocess, stdin EOF + // would close the event loop on its own and shutdown would look fine + // even with broken handlers. + await rpc(child, { + method: 'tools/call', + params: { + name: 'navigate_page', + arguments: {url: 'about:blank'}, + }, + }); +} + +describe('shutdown', () => { + it('exits within budget on stdin EOF after Chrome launch', async () => { + const child = await spawnServer(); + await initializeAndLaunchBrowser(child); + child.stdin.end(); + const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); + assert.ok( + elapsedMs < SHUTDOWN_BUDGET_MS, + `stdin-EOF shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, + ); + }); + + it('exits within budget on SIGTERM after Chrome launch', async () => { + const child = await spawnServer(); + await initializeAndLaunchBrowser(child); + child.kill('SIGTERM'); + const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); + assert.ok( + elapsedMs < SHUTDOWN_BUDGET_MS, + `SIGTERM shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, + ); + }); + + it('exits within budget on SIGINT after Chrome launch', async () => { + const child = await spawnServer(); + await initializeAndLaunchBrowser(child); + child.kill('SIGINT'); + const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); + assert.ok( + elapsedMs < SHUTDOWN_BUDGET_MS, + `SIGINT shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, + ); + }); + + it('exits within budget on SIGHUP after Chrome launch', async () => { + const child = await spawnServer(); + await initializeAndLaunchBrowser(child); + child.kill('SIGHUP'); + const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS); + assert.ok( + elapsedMs < SHUTDOWN_BUDGET_MS, + `SIGHUP shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`, + ); + }); +}); diff --git a/tests/telemetry/ClearcutLogger.test.ts b/tests/telemetry/ClearcutLogger.test.ts index cab0f44..c19567d 100644 --- a/tests/telemetry/ClearcutLogger.test.ts +++ b/tests/telemetry/ClearcutLogger.test.ts @@ -10,10 +10,7 @@ import {describe, it, afterEach, beforeEach} from 'node:test'; import sinon from 'sinon'; import {DAEMON_CLIENT_NAME} from '../../src/daemon/utils.js'; -import { - ClearcutLogger, - sanitizeParams, -} from '../../src/telemetry/ClearcutLogger.js'; +import {ClearcutLogger} from '../../src/telemetry/ClearcutLogger.js'; import {ErrorCode} from '../../src/telemetry/errors.js'; import type {Persistence} from '../../src/telemetry/persistence.js'; import {FilePersistence} from '../../src/telemetry/persistence.js'; @@ -260,95 +257,6 @@ describe('ClearcutLogger', () => { }); }); - describe('sanitizeParams', () => { - it('filters out uid and transforms strings and arrays', () => { - const schema = { - uid: zod.string(), - myString: zod.string(), - myArray: zod.array(zod.string()), - myNumber: zod.number(), - myBool: zod.boolean(), - myEnum: zod.enum(['a', 'b']), - }; - - const params = { - uid: 'sensitive', - myString: 'hello', - myArray: ['one', 'two'], - myNumber: 42, - myBool: true, - myEnum: 'a' as const, - }; - - const sanitized = sanitizeParams(params, schema); - - assert.deepStrictEqual(sanitized, { - my_string_length: 5, - my_array_count: 2, - my_number: 42, - my_bool: true, - my_enum: 'a', - }); - }); - - it('bucketizes string lengths correctly', () => { - const schema = { - str0: zod.string(), - str1: zod.string(), - str3: zod.string(), - str5: zod.string(), - str10000: zod.string(), - str10001: zod.string(), - }; - - const params = { - str0: '', - str1: 'a', - str3: 'abc', - str5: 'abcde', - str10000: 'a'.repeat(10000), - str10001: 'a'.repeat(10001), - }; - - const sanitized = sanitizeParams(params, schema); - - assert.strictEqual(sanitized.str0_length, 0); - assert.strictEqual(sanitized.str1_length, 1); - assert.strictEqual(sanitized.str3_length, 5); // snaps to 5 - assert.strictEqual(sanitized.str5_length, 5); - assert.strictEqual(sanitized.str10000_length, 10000); - assert.strictEqual(sanitized.str10001_length, 10000); // snaps to 10000 - }); - - it('throws error for unsupported types', () => { - const schema = { - myObj: zod.object({foo: zod.string()}), - }; - const params = { - myObj: {foo: 'bar'}, - }; - - assert.throws( - () => sanitizeParams(params, schema), - /Unsupported zod type for tool parameter: ZodObject/, - ); - }); - - it('throws error when value is not of equivalent type', () => { - const schema = { - myString: zod.string(), - }; - const params = { - myString: 123, - }; - - assert.throws( - () => sanitizeParams(params, schema), - /parameter myString has type ZodString but value 123 is not of equivalent type/, - ); - }); - }); - describe('Singleton', () => { it('returns undefined if not initialized', () => { assert.strictEqual(ClearcutLogger.get(), undefined); diff --git a/tests/telemetry/flagUtils.test.ts b/tests/telemetry/flagUtils.test.ts index 5c3bda5..210f3b5 100644 --- a/tests/telemetry/flagUtils.test.ts +++ b/tests/telemetry/flagUtils.test.ts @@ -108,6 +108,18 @@ describe('computeFlagUsage', () => { const usage = computeFlagUsage(args, mockOptions); assert.equal(usage.string_flag_present, false); }); + + it('sanitizes flag names containing underscores before numbers', () => { + const mock3pOptions = { + experimental3pTool: { + type: 'boolean' as const, + description: 'A 3p flag', + }, + } as unknown as typeof cliOptions; + const args = {experimental3pTool: true}; + const usage = computeFlagUsage(args, mock3pOptions); + assert.equal(usage.experimental3p_tool, true); + }); }); }); @@ -143,4 +155,19 @@ describe('getPossibleFlagMetrics', () => { }, ]); }); + + it('sanitizes flag names containing underscores before numbers', () => { + const mock3pOptions = { + experimental3pTool: { + type: 'boolean' as const, + description: 'A 3p flag', + }, + } as unknown as typeof cliOptions; + const metrics = getPossibleFlagMetrics(mock3pOptions); + + assert.deepEqual(metrics, [ + {name: 'experimental3p_tool_present', flagType: 'boolean'}, + {name: 'experimental3p_tool', flagType: 'boolean'}, + ]); + }); }); diff --git a/tests/telemetry/metricUtils.test.ts b/tests/telemetry/metricUtils.test.ts deleted file mode 100644 index 36bda86..0000000 --- a/tests/telemetry/metricUtils.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/metricUtils.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/toolMetricsUtils.test.ts b/tests/telemetry/metricsRegistry.test.ts similarity index 91% rename from tests/telemetry/toolMetricsUtils.test.ts rename to tests/telemetry/metricsRegistry.test.ts index 9033bee..53c335a 100644 --- a/tests/telemetry/toolMetricsUtils.test.ts +++ b/tests/telemetry/metricsRegistry.test.ts @@ -11,12 +11,12 @@ import { applyToExistingMetrics, generateToolMetrics, validateEnumHomogeneity, -} from '../../src/telemetry/toolMetricsUtils.js'; +} from '../../src/telemetry/metricsRegistry.js'; import {zod} from '../../src/third_party/index.js'; import {ToolCategory} from '../../src/tools/categories.js'; import type {ToolDefinition} from '../../src/tools/ToolDefinition.js'; -describe('toolMetricsUtils', () => { +describe('metricsRegistry', () => { describe('validateEnumHomogeneity', () => { it('should return the primitive type of a homogeneous enum', () => { const result = validateEnumHomogeneity(['a', 'b', 'c']); @@ -82,6 +82,26 @@ describe('toolMetricsUtils', () => { assert.strictEqual(metrics[0].args[0].name, 'arg_enum'); assert.strictEqual(metrics[0].args[0].argType, 'string'); }); + + it('should sanitize tool names containing underscores before numbers', () => { + const mockTool: ToolDefinition = { + name: 'list_3p_developer_tools', + description: 'test description', + annotations: { + category: ToolCategory.THIRD_PARTY, + readOnlyHint: true, + }, + schema: {}, + blockedByDialog: false, + handler: async () => { + // no-op + }, + }; + + const metrics = generateToolMetrics([mockTool]); + assert.strictEqual(metrics.length, 1); + assert.strictEqual(metrics[0].name, 'list3p_developer_tools'); + }); }); describe('applyToExistingMetrics', () => { diff --git a/tests/telemetry/transformation.test.ts b/tests/telemetry/transformation.test.ts new file mode 100644 index 0000000..41f9079 --- /dev/null +++ b/tests/telemetry/transformation.test.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import { + bucketizeLatency, + sanitizeParams, + stripUnderscoreBeforeNumber, + transformArgName, +} from '../../src/telemetry/transformation.js'; +import {zod} from '../../src/third_party/index.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); + }); +}); + +describe('sanitizeParams', () => { + it('filters out uid and transforms strings and arrays', () => { + const schema = { + uid: zod.string(), + myString: zod.string(), + myArray: zod.array(zod.string()), + myNumber: zod.number(), + myBool: zod.boolean(), + myEnum: zod.enum(['a', 'b']), + }; + + const params = { + uid: 'sensitive', + myString: 'hello', + myArray: ['one', 'two'], + myNumber: 42, + myBool: true, + myEnum: 'a' as const, + }; + + const sanitized = sanitizeParams(params, schema); + + assert.deepStrictEqual(sanitized, { + my_string_length: 5, + my_array_count: 2, + my_number: 42, + my_bool: true, + my_enum: 'a', + }); + }); + + it('bucketizes string lengths correctly', () => { + const schema = { + str0: zod.string(), + str1: zod.string(), + str3: zod.string(), + str5: zod.string(), + str10000: zod.string(), + str10001: zod.string(), + }; + + const params = { + str0: '', + str1: 'a', + str3: 'abc', + str5: 'abcde', + str10000: 'a'.repeat(10000), + str10001: 'a'.repeat(10001), + }; + + const sanitized = sanitizeParams(params, schema); + + assert.strictEqual(sanitized.str0_length, 0); + assert.strictEqual(sanitized.str1_length, 1); + assert.strictEqual(sanitized.str3_length, 5); // snaps to 5 + assert.strictEqual(sanitized.str5_length, 5); + assert.strictEqual(sanitized.str10000_length, 10000); + assert.strictEqual(sanitized.str10001_length, 10000); // snaps to 10000 + }); + + it('throws error for unsupported types', () => { + const schema = { + myObj: zod.object({foo: zod.string()}), + }; + const params = { + myObj: {foo: 'bar'}, + }; + + assert.throws( + () => sanitizeParams(params, schema), + /Unsupported zod type for tool parameter: ZodObject/, + ); + }); + + it('throws error when value is not of equivalent type', () => { + const schema = { + myString: zod.string(), + }; + const params = { + myString: 123, + }; + + assert.throws( + () => sanitizeParams(params, schema), + /parameter myString has type ZodString but value 123 is not of equivalent type/, + ); + }); +}); + +describe('stripUnderscoreBeforeNumber', () => { + it('removes underscores immediately preceding numbers', () => { + assert.strictEqual( + stripUnderscoreBeforeNumber('list_3p_developer_tools'), + 'list3p_developer_tools', + ); + assert.strictEqual( + stripUnderscoreBeforeNumber('make_2g_network_request'), + 'make2g_network_request', + ); + assert.strictEqual( + stripUnderscoreBeforeNumber('no_numbers_here'), + 'no_numbers_here', + ); + }); +}); + +describe('transformArgName', () => { + it('sanitizes argument names containing underscores before numbers', () => { + assert.strictEqual( + transformArgName('ZodNumber', 'my3pParam'), + 'my3p_param', + ); + assert.strictEqual( + transformArgName('ZodString', 'my3pParam'), + 'my3p_param_length', + ); + }); +}); diff --git a/tests/third_party_notices.test.js.snapshot b/tests/third_party_notices.test.js.snapshot index 2decc2d..4b94126 100644 --- a/tests/third_party_notices.test.js.snapshot +++ b/tests/third_party_notices.test.js.snapshot @@ -878,721 +878,6 @@ URL: https://github.com/puppeteer/puppeteer/tree/main/packages/browsers Version: License: Apache-2.0 --------------------- 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 diff --git a/tests/third_party_notices.test.ts b/tests/third_party_notices.test.ts index 151609d..c502a2c 100644 --- a/tests/third_party_notices.test.ts +++ b/tests/third_party_notices.test.ts @@ -19,7 +19,7 @@ describe('THIRD_PARTY_NOTICES', () => { const normalizedContent = content .replace(/^Version: .*$/gm, 'Version: ') .replaceAll('\r', ''); - t.assert.snapshot?.(normalizedContent); + t.assert.snapshot(normalizedContent); } }); }); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index f960353..12146af 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -71,7 +71,7 @@ describe('console', () => { ); const formattedResponse = await response.handle('test', context); const textContent = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(textContent); + t.assert.snapshot(textContent); }); }); @@ -195,7 +195,7 @@ describe('console', () => { 'list_console_messages', context, ); - t.assert.snapshot?.(JSON.stringify(result)); + t.assert.snapshot(JSON.stringify(result)); await dialog.dismiss(); }); }); @@ -258,7 +258,7 @@ describe('console', () => { context, ); const formattedResponse = await response2.handle('test', context); - t.assert.snapshot?.(getTextContent(formattedResponse.content[0])); + t.assert.snapshot(getTextContent(formattedResponse.content[0])); }); }); it('gets issue details with request id parsing', async t => { @@ -319,7 +319,7 @@ describe('console', () => { .replaceAll(/ID: \d+/g, 'ID: ') .replaceAll(/reqid=\d+/g, 'reqid=') .replaceAll(/localhost:\d+/g, 'hostname:port'); - t.assert.snapshot?.(sanitizedText); + t.assert.snapshot(sanitizedText); }); }); }); @@ -349,7 +349,7 @@ describe('console', () => { const formattedResponse = await response.handle('test', context); const rawText = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(rawText); + t.assert.snapshot(rawText); }); }); @@ -378,7 +378,7 @@ describe('console', () => { const formattedResponse = await response.handle('test', context); const rawText = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(rawText); + t.assert.snapshot(rawText); }); }); @@ -407,7 +407,7 @@ describe('console', () => { const formattedResponse = await response.handle('test', context); const rawText = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(rawText); + t.assert.snapshot(rawText); }); }); @@ -436,7 +436,7 @@ describe('console', () => { const formattedResponse = await response.handle('test', context); const rawText = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(rawText); + t.assert.snapshot(rawText); }); }); @@ -465,7 +465,7 @@ describe('console', () => { const formattedResponse = await response.handle('test', context); const rawText = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(rawText); + t.assert.snapshot(rawText); }); }); @@ -508,7 +508,7 @@ describe('console', () => { const formattedResponse = await response.handle('test', context); const rawText = getTextContent(formattedResponse.content[0]); - t.assert.snapshot?.(rawText); + t.assert.snapshot(rawText); }); }); @@ -540,7 +540,7 @@ describe('console', () => { ); const result = await response.handle('get_console_message', context); - t.assert.snapshot?.( + t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(result.structuredContent), null, diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts index 721505f..09850b9 100644 --- a/tests/tools/emulation.test.ts +++ b/tests/tools/emulation.test.ts @@ -5,7 +5,8 @@ */ import assert from 'node:assert'; -import {beforeEach, describe, it} from 'node:test'; +import type {IncomingHttpHeaders} from 'node:http'; +import {beforeEach, describe, it, mock} from 'node:test'; import {emulate} from '../../src/tools/emulation.js'; import { @@ -75,7 +76,7 @@ describe('emulation', () => { }); it('parses latitude and longitude', () => { - assert.deepStrictEqual(geolocationTransform('48.137154x11.576124'), { + assert.deepStrictEqual(geolocationTransform('48.137154,11.576124'), { latitude: 48.137154, longitude: 11.576124, }); @@ -208,6 +209,36 @@ describe('emulation', () => { }); }); + it('applies cpu throttling to secondary session', async () => { + await withMcpContext(async (response, context) => { + const mcpPage = context.getSelectedMcpPage(); + const universe = context.getDevToolsUniverse(mcpPage); + assert.ok(universe); + + const sendSpy = mock.method(universe.session, 'send'); + + await emulate.handler( + { + params: { + cpuThrottlingRate: 4, + }, + page: mcpPage, + }, + response, + context, + ); + + assert.ok(sendSpy.mock.calls.length > 0); + const cpuCall = sendSpy.mock.calls.find( + call => call.arguments[0] === 'Emulation.setCPUThrottlingRate', + ); + assert.ok(cpuCall); + assert.deepStrictEqual(cpuCall.arguments[1], {rate: 4}); + + sendSpy.mock.restore(); + }); + }); + it('disables cpu throttling', async () => { await withMcpContext(async (response, context) => { await context.emulate({ @@ -571,6 +602,172 @@ describe('emulation', () => { }); }); + describe('extraHttpHeaders', () => { + it('sets extra headers on requests', async () => { + let receivedHeaders: IncomingHttpHeaders = {}; + server.addRoute('/headers-test', async (req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Headers Test
'); + }); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Custom-Header': 'test-value'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + await page.goto(server.getRoute('/headers-test')); + assert.strictEqual(receivedHeaders['x-custom-header'], 'test-value'); + }); + }); + + it('clears extra headers when null is passed', async () => { + let receivedHeaders: IncomingHttpHeaders = {}; + server.addRoute('/headers-clear', async (req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Headers Clear
'); + }); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + // Set headers first + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-To-Clear': 'value'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + // Clear headers + await emulate.handler( + { + params: { + extraHttpHeaders: {}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + await page.goto(server.getRoute('/headers-clear')); + assert.strictEqual(receivedHeaders['x-to-clear'], undefined); + assert.strictEqual( + context.getSelectedMcpPage().emulationSettings.extraHttpHeaders, + undefined, + ); + }); + }); + + it('headers persist across navigations', async () => { + const receivedHeaders: IncomingHttpHeaders[] = []; + server.addRoute('/persist-one', async (req, res) => { + receivedHeaders.push({...req.headers}); + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Page One
'); + }); + server.addRoute('/persist-two', async (req, res) => { + receivedHeaders.push({...req.headers}); + res.writeHead(200, {'Content-Type': 'text/html'}); + res.end('
Page Two
'); + }); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Persist': 'yes'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + await page.goto(server.getRoute('/persist-one')); + await page.goto(server.getRoute('/persist-two')); + + assert.strictEqual(receivedHeaders[0]?.['x-persist'], 'yes'); + assert.strictEqual(receivedHeaders[1]?.['x-persist'], 'yes'); + }); + }); + + it('does not affect other emulation settings', async () => { + await withMcpContext(async (response, context) => { + // Set userAgent first + await emulate.handler( + { + params: { + userAgent: 'MyUA', + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + // Set extraHTTPHeaders separately + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Test': 'value'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const settings = context.getSelectedMcpPage().emulationSettings; + assert.deepStrictEqual(settings.extraHttpHeaders, { + 'X-Test': 'value', + }); + }); + }); + + it('reports correctly for the currently selected page', async () => { + await withMcpContext(async (response, context) => { + await emulate.handler( + { + params: { + extraHttpHeaders: {'X-Page': 'one'}, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + assert.deepStrictEqual( + context.getSelectedMcpPage().emulationSettings.extraHttpHeaders, + {'X-Page': 'one'}, + ); + + const page = await context.newPage(); + context.selectPage(page); + + assert.strictEqual( + context.getSelectedMcpPage().emulationSettings.extraHttpHeaders, + undefined, + ); + }); + }); + }); + describe('colorScheme', () => { it('emulates color scheme', async () => { await withMcpContext(async (response, context) => { diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 6d715f4..a53e0cf 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -27,7 +27,7 @@ import { } from '../../src/tools/input.js'; import {parseKey} from '../../src/utils/keyboard.js'; import {serverHooks} from '../server.js'; -import {html, withMcpContext} from '../utils.js'; +import {html, withMcpContext, getTextContent} from '../utils.js'; describe('input', () => { const server = serverHooks(); @@ -148,19 +148,19 @@ describe('input', () => { await click.handler( { params: { - uid: '1_1', + uid: '1_2', }, page: context.getSelectedMcpPage(), }, response, context, ); + const result = await response.handle('click', context); + const textContent = getTextContent(result.content[0]); const expectedUrl = server.getRoute('/after-click'); assert.ok( - response.responseLines.some( - line => line === `Page navigated to ${expectedUrl}.`, - ), - `Expected response to mention navigation to ${expectedUrl}, got: ${response.responseLines.join(' | ')}`, + textContent.includes(`Page navigated to ${expectedUrl}.`), + `Expected response to mention navigation to ${expectedUrl}, got: ${textContent}`, ); }); }); @@ -184,11 +184,11 @@ describe('input', () => { response, context, ); + const result = await response.handle('click', context); + const textContent = getTextContent(result.content[0]); assert.ok( - !response.responseLines.some(line => - line.startsWith('Page navigated to '), - ), - `Did not expect a navigation line, got: ${response.responseLines.join(' | ')}`, + !textContent.includes('Page navigated to '), + `Did not expect a navigation line, got: ${textContent}`, ); }); }); @@ -772,7 +772,7 @@ describe('input', () => { await fill.handler( { params: { - uid: '1_1', // email input + uid: '1_2', // email input value: 'new@test.com', }, page: context.getSelectedMcpPage(), @@ -790,7 +790,7 @@ describe('input', () => { await fill.handler( { params: { - uid: '1_2', // password input + uid: '1_3', // password input value: 'secret', }, page: context.getSelectedMcpPage(), @@ -1087,11 +1087,11 @@ describe('input', () => { params: { elements: [ { - uid: '1_2', + uid: '1_3', value: 'test', }, { - uid: '1_4', + uid: '1_5', value: 'test2', }, ], @@ -1193,7 +1193,7 @@ describe('input', () => { await uploadFile.handler( { params: { - uid: '1_1', + uid: '1_2', filePath: testFilePath, }, page: context.getSelectedMcpPage(), diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot index 4bcd2d0..1a9189d 100644 --- a/tests/tools/memory.test.js.snapshot +++ b/tests/tools/memory.test.js.snapshot @@ -1,181 +1,190 @@ -exports[`memory > get_memory_snapshot_details > with default options 1`] = ` +exports[`memory > get_heapsnapshot_class_nodes > with default options 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +25307,Array,object,2,0.2 kB,2.1 kB +46355,Array,object,2,0.2 kB,2.1 kB +33187,Array,object,2,0.2 kB,1.7 kB +36255,Array,object,2,0.2 kB,1.7 kB +45901,Array,object,5,0.1 kB,0.1 kB +46151,Array,object,5,0.1 kB,0.1 kB +45899,Array,object,5,0.1 kB,0.1 kB +46149,Array,object,5,0.1 kB,0.1 kB +Showing 1-8 of 8 (Page 1 of 1). +`; + +exports[`memory > get_heapsnapshot_details > with default options 1`] = ` ## Heap Snapshot Data Showing 1-157 of 157 (Page 1 of 1). -uid,className,count,selfSize,maxRetainedSize -2,"(system)",3205,199264,655004 -11,"Function",3581,111464,237120 -16,"(object shape)",2878,181480,184320 -3,"(array)",34,41416,117472 -42,"Window (global*) / https://example.com",2,81068,101832 -94,"Window (prototype) / https://example.com",4,53336,53712 -1,"(compiled code)",713,42812,46988 -6,"(string)",846,27880,27880 -96,"Window (internal cache) / https://example.com",4,400,20256 -15,"Object (global*)",2,3856,13488 -55,"{constructor, toString, toDateString, toTimeString, toISOString, toUTCString, toGMTString, getDate, setDate, getDay}",4,848,8848 -49,"TypedArray",48,1424,8784 -109,"Document",1,8448,8700 -50,"Error",48,1344,7872 -47,"Math",4,816,7792 -19,"Array",8,1056,7728 -39,"{constructor, buffer, get buffer, byteLength, get byteLength, byteOffset, get byteOffset, length, get length}",4,624,7008 -10,"HTMLDocument",1,3288,6192 -44,"console",4,488,6152 -32,"String",4,960,6056 -68,"DataView",4,480,5504 -105,"HTMLElement",1,4728,4768 -62,"Intl.Locale",4,288,4672 -104,"Element",1,4224,4540 -76,"{constructor, getColumnNumber, getEnclosingColumnNumber, getEnclosingLineNumber, getEvalOrigin, getFileName, getFunction}",4,480,4448 -45,"Intl",4,336,4192 -25,"{, constructor, get constructor, set constructor, reduce, toArray, forEach, some, every, find}",4,288,3600 -89,"system / Context",116,2544,3488 -43,"Atomics",4,336,3056 -91,"DisposableStack",4,240,3040 -92,"AsyncDisposableStack",4,240,3040 -46,"Reflect",4,336,2480 -72,"ArrayBuffer",4,192,2160 -9,"Window / https://example.com",1,904,1944 -37,"WebAssembly",4,416,1904 -58,"{constructor, , resolvedOptions, adoptText, get adoptText, first, get first, next, get next}",4,112,1808 -103,"Node",1,1764,1804 -126,"Performance",1,1040,1784 -30,"Set",4,384,1616 -40,"{constructor}",14,392,1560 -90,"SharedArrayBuffer",4,112,1552 -56,"Intl.NumberFormat",4,192,1536 -66,"Intl.DateTimeFormat",4,192,1536 -27,"Map",4,336,1504 -48,"Number",4,240,1488 -70,"Symbol",4,192,1376 -67,"{at, copyWithin, entries, fill, find, findIndex, findLast, findLastIndex, flat, flatMap, includes, keys, toReversed}",4,336,1344 -106,"HTMLBodyElement (prototype) / https://example.com",1,1188,1228 -54,"Object",22,928,1168 -59,"Intl.PluralRules",4,192,1168 -60,"Intl.RelativeTimeFormat",4,192,1168 -61,"Intl.ListFormat",4,192,1168 -65,"Intl.DurationFormat",4,192,1168 -69,"BigInt",4,192,1168 -28,"{constructor, __defineGetter__, __defineSetter__, hasOwnProperty, __lookupGetter__, __lookupSetter__, isPrototypeOf}",4,288,1128 -17,"{isTraceCategoryEnabled, trace, getContinuationPreservedEmbedderData, setContinuationPreservedEmbedderData, console}",4,192,1120 -130,"StyleEngine",1,912,1104 -118,"CSSStyleRule",8,576,1024 -21,"Generator",4,192,1008 -23,"AsyncGenerator",4,192,1008 -34,"JSON",4,192,1008 -35,"Promise",4,192,1000 -57,"Intl.Collator",4,112,976 -119,"CSSStyleSheet",3,528,976 -33,"WeakMap",4,240,960 -52,"FinalizationRegistry",4,112,928 -63,"Intl.DisplayNames",4,112,928 -64,"Intl.Segmenter",4,112,928 -73,"Async-from-Sync Iterator",4,112,880 -22,"{, }",4,112,864 -85,"{containing, }",4,112,832 -51,"WeakRef",4,112,768 -79,"WebAssembly.Table",4,192,768 -80,"WebAssembly.Memory",4,192,768 -71,"Boolean",4,144,752 -18,"Array Iterator",4,112,720 -24,"Iterator Helper",4,112,720 -26,"Map Iterator",4,112,720 -29,"Set Iterator",4,112,720 -31,"String Iterator",4,112,720 -86,"Segmenter String Iterator",4,112,720 -88,"RegExp String Iterator",4,112,720 -20,"{constructor, name, message, toString}",4,112,704 -87,"{next, return}",4,112,672 -75,"AsyncGeneratorFunction",4,112,656 -84,"GeneratorFunction",4,112,656 -74,"AsyncFunction",4,112,608 -81,"WebAssembly.Global",4,112,592 -93,"WebAssembly.Suspending",4,112,592 -100,"{loadTimes, csi}",2,56,580 -36,"{constructor, exec, dotAll, get dotAll, flags, get flags, global, get global, hasIndices, get hasIndices, ignoreCase}",4,240,576 -78,"WebAssembly.Instance",4,112,544 -83,"WebAssembly.Exception",4,112,544 -110,"Text",6,480,480 -77,"WebAssembly.Module",4,112,448 -82,"WebAssembly.Tag",4,112,448 -101,"EventTarget",2,92,436 -53,"WeakSet",4,192,416 -135,"EventListener",10,400,400 -99,"{parse, stringify}",2,40,384 -127,"Navigation",1,152,384 -4,"{, }",8,224,352 -134,"FontFaceSet",1,328,328 -133,"StylePropertyMap",8,320,320 -139,"MutationObserver",2,320,320 -102,"WindowProperties",2,56,280 -115,"