diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 0000000..ecb242a
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,80 @@
+name: Nightly compatibility
+
+on:
+ schedule:
+ - cron: '17 18 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nightly-compatibility
+ cancel-in-progress: false
+
+# The reusable gate supplies the complete Ubuntu runtime matrix. These jobs add
+# Windows/macOS across every declared runtime. As of 2026-08-28, Node 18/20 are
+# EOL compatibility lines; 22/24 are LTS and 26 is Current. Evidence:
+# https://github.com/nodejs/Release#release-schedule
+jobs:
+ quality:
+ name: Reusable Ubuntu quality matrix
+ uses: ./.github/workflows/reusable-quality.yml
+ permissions:
+ contents: read
+
+ legacy-os-runtime:
+ name: Legacy / ${{ matrix.os }} / Node ${{ matrix.node }}
+ needs: quality
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ node: [18.18.0, 20, 22, 24, 26]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.quality.outputs.package_artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Run the node:test Legacy controller against the exact tarball
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ HTTP_PROXY: ''
+ HTTPS_PROXY: ''
+ ALL_PROXY: ''
+ NO_PROXY: 127.0.0.1,localhost
+ run: node --test packages/network-debugger/test/e2e/pack/legacy-runtime.test.mjs
+
+ native-os-runtime:
+ name: Native / ${{ matrix.os }} / Node ${{ matrix.node }}
+ needs: quality
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ node: [22, 24, 26]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.quality.outputs.package_artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Run the node:test Native controller against the exact tarball
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ HTTP_PROXY: ''
+ HTTPS_PROXY: ''
+ ALL_PROXY: ''
+ NO_PROXY: 127.0.0.1,localhost
+ run: node --test packages/network-debugger/test/e2e/pack/native-runtime.test.mjs
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index b9540fa..f1c751d 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -4,20 +4,58 @@ on:
release:
types: [published]
+permissions:
+ contents: read
+ id-token: write
+
+concurrency:
+ group: npm-publish-${{ github.event.release.tag_name }}
+ cancel-in-progress: false
+
jobs:
+ quality:
+ name: Rebuild once and run the reusable release gate
+ uses: ./.github/workflows/reusable-quality.yml
+ permissions:
+ contents: read
+
publish:
+ name: Publish the exact tested tarball
+ needs: quality
runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ id-token: write
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ # npm trusted publishing requires npm >=11.5.1 and Node >=22.14. The
+ # official configuration is OIDC-first; NPM_TOKEN remains a migration
+ # fallback but cannot bypass the reusable quality dependency above.
+ # https://docs.npmjs.com/trusted-publishers/
+ - uses: actions/setup-node@v4
with:
- node-version: 22
- registry-url: 'https://registry.npmjs.org/'
- - run: npm i -g pnpm@9.12.2
- - run: pnpm i
- - run: pnpm build --filter=node-network-devtools
- - run: |
- cd packages/network-debugger
- npm publish --access public
+ node-version: 24
+ registry-url: https://registry.npmjs.org/
+ - run: npm install --global npm@11
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.quality.outputs.package_artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Verify release candidate identity
+ shell: bash
+ env:
+ EXPECTED_TARBALL: ${{ needs.quality.outputs.package_tarball_name }}
+ EXPECTED_SHA256: ${{ needs.quality.outputs.package_sha256 }}
+ EXPECTED_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ set -euo pipefail
+ tarball="$RUNNER_TEMP/npm-package/$EXPECTED_TARBALL"
+ test -f "$tarball"
+ echo "$EXPECTED_SHA256 $tarball" | sha256sum --check --strict
+ package_version="$(tar -xOf "$tarball" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")"
+ test "${EXPECTED_TAG#v}" = "$package_version"
+ - name: Publish with OIDC provenance (or NPM_TOKEN fallback)
env:
- NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ TARBALL: ${{ runner.temp }}/npm-package/${{ needs.quality.outputs.package_tarball_name }}
+ run: npm publish "$TARBALL" --access public --provenance
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 0000000..ae60928
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,34 @@
+name: Quality
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - 'codex/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: quality-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ name: Required package and protocol quality
+ uses: ./.github/workflows/reusable-quality.yml
+ permissions:
+ contents: read
+
+ quality-gate:
+ name: Quality Gate
+ if: always()
+ needs: quality
+ runs-on: ubuntu-latest
+ steps:
+ - name: Enforce the complete reusable workflow result
+ env:
+ QUALITY_RESULT: ${{ needs.quality.result }}
+ run: test "$QUALITY_RESULT" = success
diff --git a/.github/workflows/reusable-quality.yml b/.github/workflows/reusable-quality.yml
new file mode 100644
index 0000000..fb2ed22
--- /dev/null
+++ b/.github/workflows/reusable-quality.yml
@@ -0,0 +1,299 @@
+name: Reusable package quality
+
+on:
+ workflow_call:
+ outputs:
+ package_artifact_name:
+ description: Artifact containing the one tested npm tarball
+ value: ${{ jobs.package.outputs.artifact_name }}
+ package_tarball_name:
+ description: Exact npm tarball filename
+ value: ${{ jobs.package.outputs.tarball_name }}
+ package_sha256:
+ description: SHA-256 of the exact npm tarball
+ value: ${{ jobs.package.outputs.sha256 }}
+
+permissions:
+ contents: read
+
+# Matrix reviewed 2026-08-28 against the official Node.js release schedule:
+# https://github.com/nodejs/Release#release-schedule
+# Node 22/24 are LTS and Node 26 is Current. Node 18 (EOL 2025-04-30) and
+# Node 20 (EOL 2026-04-30) remain compatibility lines: Node 20 runs unit and
+# Legacy evidence, while Node 18 is Legacy-only. Every runtime controller below
+# is explicit --
+# unsupported capability combinations are not silently skipped at runtime.
+env:
+ HTTP_PROXY: ''
+ HTTPS_PROXY: ''
+ ALL_PROXY: ''
+ NO_PROXY: 127.0.0.1,localhost
+ NETWORK_DEBUGGER_E2E_ARTIFACT_DIR: packages/network-debugger/test/e2e/artifacts
+
+jobs:
+ unit-supported:
+ name: Unit / Ubuntu / Node ${{ matrix.node }}${{ matrix.lifecycle == 'eol' && ' (EOL compatibility)' || '' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - node: 20
+ lifecycle: eol
+ - node: 22
+ lifecycle: supported
+ - node: 24
+ lifecycle: supported
+ - node: 26
+ lifecycle: supported
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9.12.2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter node-network-devtools test:unit
+
+ package:
+ name: Build and npm pack exactly once
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ outputs:
+ artifact_name: ${{ steps.pack.outputs.artifact_name }}
+ tarball_name: ${{ steps.pack.outputs.tarball_name }}
+ sha256: ${{ steps.pack.outputs.sha256 }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9.12.2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter node-network-devtools build
+ - name: Create the sole release candidate tarball
+ id: pack
+ shell: bash
+ run: |
+ set -euo pipefail
+ package_dir="$RUNNER_TEMP/npm-package"
+ mkdir -p "$package_dir"
+ npm pack --pack-destination "$package_dir" ./packages/network-debugger
+ mapfile -t tarballs < <(find "$package_dir" -maxdepth 1 -type f -name '*.tgz' -print)
+ if [[ "${#tarballs[@]}" -ne 1 ]]; then
+ echo "Expected exactly one npm tarball, found ${#tarballs[@]}" >&2
+ exit 1
+ fi
+ tarball_name="$(basename "${tarballs[0]}")"
+ sha256="$(sha256sum "${tarballs[0]}" | cut -d ' ' -f 1)"
+ artifact_name="node-network-devtools-tgz-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT"
+ echo "tarball_name=$tarball_name" >> "$GITHUB_OUTPUT"
+ echo "sha256=$sha256" >> "$GITHUB_OUTPUT"
+ - name: Upload the exact release candidate
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.pack.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package/*.tgz
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 14
+
+ pack-consumer:
+ name: Packed CJS and ESM consumers / Node 24
+ needs: package
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.package.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Resolve public entries from an installed tarball
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ run: node --test packages/network-debugger/test/e2e/pack/pack-consumer.test.mjs
+
+ legacy-runtime:
+ name: Legacy runtime / Ubuntu / Node ${{ matrix.node }}${{ matrix.lifecycle == 'eol' && ' (EOL compatibility)' || '' }}
+ needs: package
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - node: 18.18.0
+ lifecycle: eol
+ - node: 20
+ lifecycle: eol
+ - node: 22
+ lifecycle: supported
+ - node: 24
+ lifecycle: supported
+ - node: 26
+ lifecycle: supported
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.package.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ # This controller intentionally uses node:test + npm only. In particular,
+ # Node 18/20 compatibility never loads the current Vitest toolchain.
+ - name: Exercise a real packed Legacy CDP lifecycle
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ run: node --test packages/network-debugger/test/e2e/pack/legacy-runtime.test.mjs
+
+ native-runtime:
+ name: Native packed runtime / Ubuntu / Node ${{ matrix.node }}
+ needs: package
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ node: [22, 24, 26]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.package.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Exercise a real packed Native CDP lifecycle
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ run: node --test packages/network-debugger/test/e2e/pack/native-runtime.test.mjs
+
+ ubuntu-protocol:
+ name: Mandatory Ubuntu Native, Legacy, and CLI protocol / Node 24
+ needs: package
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9.12.2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.package.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Hydrate repository tests from the exact tarball
+ shell: bash
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ run: |
+ set -euo pipefail
+ tarball="$(find "$NND_PACK_DIR" -maxdepth 1 -type f -name '*.tgz' -print -quit)"
+ unpacked="$RUNNER_TEMP/npm-unpacked"
+ mkdir -p "$unpacked"
+ tar -xzf "$tarball" -C "$unpacked"
+ rm -rf packages/network-debugger/dist
+ cp -R "$unpacked/package/dist" packages/network-debugger/dist
+ - run: pnpm --filter node-network-devtools test:e2e:native
+ - run: pnpm --filter node-network-devtools test:e2e:legacy
+ - run: pnpm --filter node-network-devtools test:e2e:enhancements
+ - run: pnpm --filter node-network-devtools test:e2e:cli
+ - name: Upload Native, Legacy, and CLI protocol diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: native-legacy-cli-protocol-node-24
+ path: packages/network-debugger/test/e2e/artifacts
+ if-no-files-found: ignore
+
+ adapter-os-smoke:
+ name: 20-round adapter smoke / ${{ matrix.os }} / Node 24
+ needs: package
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.package.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Run isolated Native and Legacy controller for 20 rounds each
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ NND_OS_SMOKE_ROUNDS: 20
+ HTTP_PROXY: ''
+ HTTPS_PROXY: ''
+ ALL_PROXY: ''
+ NO_PROXY: 127.0.0.1,localhost
+ run: >-
+ node --test --test-concurrency=1
+ packages/network-debugger/test/e2e/os-smoke/adapter-smoke.test.mjs
+
+ frontend:
+ name: Official frontend / Ubuntu / Node 24 / Native and Legacy
+ needs: package
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9.12.2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.package.outputs.artifact_name }}
+ path: ${{ runner.temp }}/npm-package
+ - name: Hydrate repository tests from the exact tarball
+ shell: bash
+ env:
+ NND_PACK_DIR: ${{ runner.temp }}/npm-package
+ run: |
+ set -euo pipefail
+ tarball="$(find "$NND_PACK_DIR" -maxdepth 1 -type f -name '*.tgz' -print -quit)"
+ unpacked="$RUNNER_TEMP/npm-unpacked"
+ mkdir -p "$unpacked"
+ tar -xzf "$tarball" -C "$unpacked"
+ rm -rf packages/network-debugger/dist
+ cp -R "$unpacked/package/dist" packages/network-debugger/dist
+ - name: Install the lockfile-pinned Chromium
+ run: pnpm --filter node-network-devtools exec playwright install --with-deps chromium
+ - run: pnpm --filter node-network-devtools test:e2e:frontend
+ - name: Upload frontend diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: official-frontend-node-24
+ path: packages/network-debugger/test/e2e/artifacts
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 41d41e8..055b4de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ node_modules
# Testing
coverage
+.playwright-cli/
# Turbo
.turbo
@@ -43,4 +44,4 @@ yarn-error.log*
# GitHub App credentials
-gha-creds-*.json
\ No newline at end of file
+gha-creds-*.json
diff --git a/README-zh_CN.md b/README-zh_CN.md
index b8950e5..5910798 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -1,88 +1,102 @@
-
-
-
-
Node Network Devtools
+# Node Network Devtools
-
🔮 让node程序支持用chrome devtool的network选项卡调试
-
🦎 等同于浏览器的爬虫体验
-
⚙️ Powered by CDP
-
-
-
-
-
+在标准 Chrome DevTools Network 面板中查看 Node.js 发出的网络请求。
-
+[English](README.md) | 简体中文
----
+v2 提供两个互斥、完整的后端:
-[English](README.md) | 简体中文
+- **Native**:直接连接 Node 实验性的 Network Inspector,不修改应用网络 API。
+- **Legacy**:通过项目自有的标准 CDP Target,捕获 HTTP/HTTPS、Fetch、可选
+ Undici、WebSocket 帧和 SSE 消息。
-## 📖 介绍
+运行时只拥有调试 Target,不拥有 Chrome 进程。打开浏览器必须显式选择;端口默认
+交给操作系统分配;Legacy 应用传输已从旧的 5270 WebSocket/锁文件改为隔离的子进程
+IPC。
-如你所见,添加`--inspect`选项打开的node程序并不支持network标签,因为它不去代理用户请求。
-node network devtools正是为了解决这个问题,它一个允许您使用chrome devtools的network选项卡调试nodejs发出的请求,让debugger过程等同于浏览器中的网络爬虫体验。
+## 快速开始
-## 🎮 TODO
+```bash
+npm install --save-dev node-network-devtools
-- [x] HTTP/HTTPS
- - [x] req/res headers
- - [x] payload
- - [x] json str response body
- - [x] binary response body
- - [x] stack follow
- - [x] show stack
- - [x] click to jump
- - [x] base
- - [x] Sourcemap
-- [x] WebSocket
- - [x] messages
- - [x] payload
- - [x] headers
-- [ ] Compatibility
- - [x] commonjs
- - [x] esmodule
- - [ ] Bun
-- [ ] Undici
- - [ ] undici.request
- - [x] undici.fetch
+# 不改业务源码直接启动并打开 DevTools
+npx nnd dev --open src/app.js
-## 👀 预览
+# 查看当前 Node 与后端的真实能力
+npx nnd doctor --json
+```
-
+库 API:
-## 📦 快速开始
+```ts
+import { register } from 'node-network-devtools'
-### 1. 安装
+const registration = register({
+ mode: 'auto',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false }
+})
-```bash
-# npm
-npm install node-network-devtools -D
-# or pnpm
-pnpm add node-network-devtools -D
-# or yarn
-yarn add node-network-devtools -D
+const ready = await registration.ready
+console.log(ready.mode, ready.target, ready.capabilities, ready.fallbackReason)
+
+await registration.openDevtools()
+await registration.dispose()
```
-### 2. Usage
+为了兼容 v1,返回值仍然可以直接调用:`const unregister = register();
+unregister()`。
-只需将以下代码添加到项目的入口文件中即可。
+## 能力概览
-```typescript
-import { register } from 'node-network-devtools'
+| 能力 | Native | Legacy |
+| ----------------------------- | ---------------------- | ------ |
+| HTTP / HTTPS / Fetch 生命周期 | 取决于 Node 版本 | 支持 |
+| HTTP/2 | 仅 Node 22.20+(22.x) | 不支持 |
+| 响应 Body | 取决于 Node 版本 | 支持 |
+| 请求 Body | 不声明支持 | 支持 |
+| WebSocket 生命周期 / 帧 | 仅生命周期 | 支持 |
+| SSE 消息 | 不支持 | 支持 |
+| 请求/响应 Mock | 不支持 | 支持 |
-process.env.NODE_ENV === 'development' && register()
-```
+Native 能力根据实际 Node 版本和 Inspector 方法探测。强制 Native 时缺少能力会直接
+失败;Auto 改用 Legacy 时会返回结构化原因,不会静默降级。
-如果需要停止调试网络请求并消除副作用,只需使用 `register` 方法的返回值进行清理。
+Native HTTP/2 采用保守白名单:仅 Node 22.20+ 的 22.x 版本声明支持。Node 22.22.3
+已通过非空 h2c 生命周期实测;Node 24.16.0 与 26.8.1 在通过 `setEncoding()` 消费
+非空响应时,会触发上游实验性 Inspector 的 `Missing dataLength` 崩溃。其他及未来
+大版本在独立验证前一律报告为不支持;Legacy 也不捕获 HTTP/2。
-```typescript
-import { register } from 'node-network-devtools'
+## Session、HAR 与 Replay
+
+两个后端都可以持久化 Network Session、外置保存响应 Body、导出 HAR 1.2、关联已有
+`traceparent`,并回放请求:
+
+```ts
+const registration = register({
+ session: { directory: '.nnd/sessions/run-001', har: true }
+})
+
+await registration.ready
+// 执行业务请求
+await registration.dispose()
+```
-const unregister = register()
-unregister()
+```bash
+npx nnd replay --dry-run --json .nnd/sessions/run-001
+npx nnd replay capture.har
```
-## ⚙️ 配置选项
+Mock 明确只属于 Legacy。Auto 配置 `legacy.mock` 时会选择 Legacy;强制 Native 会返回
+`NND_NATIVE_MOCK_CONFLICT`。
+
+## 文档
+
+- [npm 包完整用法](packages/network-debugger/README.md)
+- [v1 到 v2 迁移说明](docs/v2-migration.md)
+- [v2 架构、Plan 与验收证据](docs/v2-implementation-plan.md)
-`register` 函数接受一个可选的 `RegisterOptions` 对象来定制其行为。有关可用选项及其详细说明的完整列表,请参阅[选项文档](apps/web/docs/zh/options.md)。
+发布包要求 Node.js `>=18.18`。Node 18/20 虽已 EOL,仍保留迁移兼容测试;新项目建议
+使用仍在维护的 Node 版本。`undici@^6` 是 peer dependency,确保选择 Legacy
+Undici 捕获时修改的是应用使用的同一个包实例。
diff --git a/README.md b/README.md
index 7c0c825..c5d4b54 100644
--- a/README.md
+++ b/README.md
@@ -1,98 +1,109 @@
-
-
-
-
Node Network Devtools
-
-
🔮 Use chrome network devtool to debugger nodejs
-
🦎 Similar web crawler experience to browsers
-
⚙️ Powered by CDP
-
-
-
-
-
-
-
-
-
-
----
+# Node Network Devtools
-English | [简体中文](README-zh_CN.md)
-
-[](https://snyk.io/advisor/npm-package/node-network-devtools)
-
-[](https://deepwiki.com/GrinZero/node-network-devtools)
+Inspect outbound Node.js traffic in the standard Chrome DevTools Network panel.
-## 📖 Introduction
-
-As you can see, the node program opened with the `--inspect` option does not support network tags because it does not proxy user requests.
-Node network devtools is designed to address this issue by allowing you to debug requests made by nodejs using the network tab of Chrome devtools, making the debugging process equivalent to a web crawler experience in the browser.
+English | [简体中文](README-zh_CN.md)
-Node v22.6.0 experimentally supports network debugging. This library supports use before node v22.6.0, but the specific supported versions are unknown.
+[](https://www.npmjs.com/package/node-network-devtools)
-## 🎮 Features
+Version 2 has two mutually exclusive backends:
-- [x] HTTP/HTTPS
- - [x] req/res headers
- - [x] payload
- - [x] json str response body
- - [x] binary response body
- - [x] stack follow
- - [x] show stack
- - [x] click to jump
- - [x] base
- - [x] Sourcemap
-- [x] WebSocket
- - [x] messages
- - [x] payload
- - [x] headers
-- [ ] Compatibility
- - [x] commonjs
- - [x] esmodule
-- [ ] Undici
- - [ ] undici.request
- - [x] undici.fetch
-- [ ] Complete unit testing of oneself
+- **Native** connects to Node's experimental Network Inspector without patching
+ application network APIs.
+- **Legacy** captures HTTP/HTTPS, Fetch, opted-in Undici, WebSocket frames, and
+ SSE through a project-owned standard CDP target.
-## 👀 Preview
+The runtime owns a target, not a Chrome process. Browser opening is explicit,
+ports default to OS assignment, and Legacy application transport uses isolated
+child-process IPC instead of the old 5270 WebSocket/lock-file design.
-
+## Quick start
-## 📦 Quick Start
+```bash
+npm install --save-dev node-network-devtools
-### 1. Install
+# Zero-code startup
+npx nnd dev --open src/app.js
-```bash
-# npm
-npm install node-network-devtools -D
-# or pnpm
-pnpm add node-network-devtools -D
-# or yarn
-yarn add node-network-devtools -D
+# Diagnose the actual runtime and adapter capabilities
+npx nnd doctor --json
```
-### 2. Usage
-
-Just add the following code to the entry file of your project.
+Library usage:
-```typescript
+```ts
import { register } from 'node-network-devtools'
-process.env.NODE_ENV === 'development' && register()
-```
+const registration = register({
+ mode: 'auto',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false }
+})
-To stop debugging network requests and eliminate side effects, just use the return value of the `register` method for cleanup.
+const ready = await registration.ready
+console.log(ready.mode, ready.target, ready.capabilities, ready.fallbackReason)
-```typescript
-import { register } from 'node-network-devtools'
+await registration.openDevtools()
+await registration.dispose()
+```
-const unregister = register()
-unregister()
+The handle remains callable for v1 compatibility: `const unregister =
+register(); unregister()`.
+
+## Capability summary
+
+| Capability | Native | Legacy |
+| ------------------------------ | ----------------------- | ------ |
+| HTTP / HTTPS / Fetch lifecycle | Runtime-dependent | Yes |
+| HTTP/2 | Node 22.20+ (22.x only) | No |
+| Response bodies | Runtime-dependent | Yes |
+| Request bodies | Not advertised | Yes |
+| WebSocket lifecycle / frames | Lifecycle only | Yes |
+| SSE messages | No | Yes |
+| Request/response Mock | No | Yes |
+
+Native values are probed from the running Node version and Inspector methods.
+Forced Native fails if requirements are missing; Auto returns a structured
+reason whenever it uses Legacy.
+
+Native HTTP/2 is conservatively allowlisted only for Node 22.20+ releases in
+the 22.x line. A non-empty h2c lifecycle passed on Node 22.22.3, while consuming
+a non-empty response with `setEncoding()` crashes the upstream experimental
+Inspector on Node 24.16.0 and 26.8.1 with `Missing dataLength`. Other and future
+majors remain reported as unsupported until independently verified; Legacy does
+not capture HTTP/2.
+
+## Session workflow
+
+Both backends support persistent Network sessions, external response bodies,
+HAR 1.2 export, traceparent correlation, and replay:
+
+```ts
+const registration = register({
+ session: { directory: '.nnd/sessions/run-001', har: true }
+})
+
+await registration.ready
+// run application traffic
+await registration.dispose()
```
-## ⚙️ Configuration Options
+```bash
+npx nnd replay --dry-run --json .nnd/sessions/run-001
+npx nnd replay capture.har
+```
+
+Mock is intentionally Legacy-only. Auto selects Legacy when `legacy.mock` rules
+are configured; forced Native reports `NND_NATIVE_MOCK_CONFLICT`.
+
+## Documentation
-The `register` function accepts an optional `RegisterOptions` object to customize its behavior. For a complete list of available options and their detailed descriptions, please refer to the [Options Documentation](apps/web/docs/options.md).
+- [npm package guide](packages/network-debugger/README.md)
+- [v1 to v2 migration](docs/v2-migration.md)
+- [v2 architecture and implementation evidence](docs/v2-implementation-plan.md)
-
\ No newline at end of file
+The package requires Node.js `>=18.18`. Node 18 and 20 remain compatibility
+lanes for migrations despite being EOL; maintained Node releases are preferred.
+`undici@^6` remains a peer dependency so opt-in Legacy interception can patch
+the application's package instance.
diff --git a/apps/web/docs/get-started.md b/apps/web/docs/get-started.md
index 6e4b4bb..d3d29c7 100644
--- a/apps/web/docs/get-started.md
+++ b/apps/web/docs/get-started.md
@@ -2,6 +2,8 @@
Node Network Devtools is a network debugging tool that integrates Chrome Devtools. It provides a network debugging experience equivalent to a browser, and is ultra easy to access. It is free of proxy competition and trouble.
+Node.js `>=18.18` is required.
+
## Install
::: code-tabs
@@ -28,7 +30,20 @@ npm i -D node-network-devtools
## Usage
-Node.js programs that support both ESM and CommonJS standards only need to introduce and call the 'register' method in the entry file.
+The CLI is the recommended zero-code entry point. `--open` opens the exact
+Inspector or Legacy target after it is ready; without it, the CLI prints the
+target and leaves browser ownership to you.
+
+```bash
+npx nnd dev --open src/app.js
+npx nnd dev --no-wait --runner tsx src/app.ts -- --port 3000
+npx nnd doctor --json
+```
+
+Native/Auto starts paused by default so the frontend cannot miss startup
+traffic. Add `--no-wait` when the application should start immediately.
+
+Library registration is also available for ESM and CommonJS applications:
::: code-tabs
@@ -36,16 +51,36 @@ Node.js programs that support both ESM and CommonJS standards only need to intro
```typescript
import { register } from 'node-network-devtools'
-register()
+
+const registration = register({
+ mode: 'auto',
+ devtools: { open: true }
+})
+
+const ready = await registration.ready
+console.log(ready.mode, ready.target.discoveryUrl)
+
+// Call during application shutdown.
+await registration.dispose()
```
@tab javascript
```javascript
const { register } = require('node-network-devtools')
-register()
+
+const registration = register({
+ mode: 'legacy',
+ devtools: { open: true }
+})
+
+registration.ready.then(({ mode, target }) => {
+ console.log(mode, target.discoveryUrl)
+})
```
:::
-If you want to use options, you can go to [options](./options.md) to see the details.
+Browser opening is opt-in and target ports default to an OS-assigned port. See
+[configuration options](./options.md) and the
+[v1 to v2 migration guide](https://github.com/GrinZero/node-network-devtools/blob/main/docs/v2-migration.md).
diff --git a/apps/web/docs/options.md b/apps/web/docs/options.md
index 22f9c49..2ab716e 100644
--- a/apps/web/docs/options.md
+++ b/apps/web/docs/options.md
@@ -1,128 +1,118 @@
-# Options
+# Configuration options
-## RegisterOptions
+The v2 API separates backend selection, Inspector target settings, frontend
+opening, Legacy hooks, and recording.
-The `RegisterOptions` interface is used to configure the registration options for the network debugger. Below are detailed descriptions of each option and their default values.
-
-### Example
-
-Here is an example of using `RegisterOptions`:
-
-```typescript
-import { RegisterOptions, register } from 'node-network-devtools'
+```ts
+import { register, type RegisterOptions } from 'node-network-devtools'
const options: RegisterOptions = {
- port: 5270,
- serverPort: 5271,
- autoOpenDevtool: true,
- intercept: {
- fetch: true,
- normal: true
+ mode: 'auto',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false },
+ session: { directory: '.nnd/sessions/local', har: true },
+ legacy: {
+ serverPort: 0,
+ intercept: { normal: true, fetch: true, undici: { fetch: false } }
}
}
-// Use options to register the network debugger
-register(options)
+const registration = register(options)
+await registration.ready
+await registration.dispose()
```
-### port
-
-- **Description**: Main process port
-- **Default value**: `5270`
-
-### serverPort
-
-- **Description**: CDP server port for Devtool
-- **Link**: [devtools://devtools/bundled/inspector.html?ws=127.0.0.1:${serverPort}](devtools://devtools/bundled/inspector.html?ws=127.0.0.1:${serverPort})
-- **Default value**: `5271`
-
-### autoOpenDevtool
-
-- **Description**: Whether to automatically open Devtool
-- **Default value**: `true`
-
-### intercept
-
-- **Description**: Options for intercepting different types of requests.
- If a property is set to `false`, that specific type of request will not be intercepted.
- By default, all are intercepted if not explicitly set.
-
-#### intercept.fetch
-
-- **Description**: Whether to intercept `fetch` requests.
-- **Default value**: `true`
+## `mode`
-#### intercept.normal
+- `auto` (default): prefer a proven Native implementation and expose a
+ structured reason when Legacy is selected.
+- `native`: require Node's experimental Network Inspector and every requested
+ capability; never fall back.
+- `legacy`: install project capture hooks and use the project CDP target.
-- **Description**: Whether to intercept `http/https` requests.
-- **Default value**: `true`
+## `requiredCapabilities`
-#### intercept.undici
+An array containing any of `http`, `https`, `fetch`, `http2`, `responseBody`,
+`requestBody`, `websocketLifecycle`, `websocketFrames`, `sseMessages`, or
+`initiator`. Selection fails or falls back if a backend does not provide every
+required value.
-- **Description**: Options for intercepting `undici` requests. Set to `false` to disable all `undici` interception. Otherwise, configure specific `undici` interception options.
-- **Default value**: `false`
-- **Options**:
- - `fetch`: Whether to intercept `undici`'s `fetch` requests. Defaults to `false`.
- - `normal`: Whether to intercept `undici`'s normal requests. Defaults to `false`.
+## `inspector`
-## ConnectOptions
+- `host`: Inspector bind host; defaults to `127.0.0.1`.
+- `port`: Inspector port; defaults to `0` for OS assignment.
-The `ConnectOptions` interface configures options for connecting to the network debugger.
+## `devtools`
-### port
+- `open`: whether library registration explicitly opens the returned target;
+ defaults to `false`.
-- **Description**: Main process port
-- **Default value**: `5270`
+The backend never owns or kills the resulting browser process.
-## UnregisterOptions
+## `session`
-The `UnregisterOptions` interface configures options for unregistering the network debugger.
+- `directory`: exact output directory for `manifest.json`, `events.ndjson`, and
+ `bodies/`. It must not already contain Session artifacts.
+- `bodyCommandTimeoutMs`: optional positive timeout for each
+ `Network.getResponseBody` command.
+- `har`: `true` writes `session.har` in the Session directory during disposal;
+ a string writes to that path; false/omitted disables automatic export.
-### port
+The recorder closes before the backend so outstanding body commands can finish.
-- **Description**: Main process port
-- **Default value**: `5270`
+## `legacy`
-## SetRequestInterceptorOptions
+### `serverPort`
-The `SetRequestInterceptorOptions` interface configures options for setting a request interceptor.
+Legacy CDP discovery/WebSocket target port. It defaults to `0`. The application
+bridge no longer uses a TCP port.
-### port
+### `intercept`
-- **Description**: Main process port
-- **Default value**: `5270`
+- `normal`: intercept `http.request/get` and `https.request/get`; default true.
+- `fetch`: intercept global Fetch; default true.
+- `undici.fetch`: opt into interception of separately installed Undici Fetch;
+ default false. `undici@^6` is a package peer so this hook observes the
+ application's module instance; install it explicitly when peer auto-install
+ is disabled.
-### request
+Disabled transports are reported as unavailable capabilities for that Legacy
+session.
-- **Description**: A function to intercept and modify outgoing requests.
+### `mock`
-## SetResponseInterceptorOptions
+An ordered array of Legacy-only request/response rules:
-The `SetResponseInterceptorOptions` interface configures options for setting a response interceptor.
-
-### port
-
-- **Description**: Main process port
-- **Default value**: `5270`
-
-### response
-
-- **Description**: A function to intercept and modify incoming responses.
-
-## RemoveRequestInterceptorOptions
-
-The `RemoveRequestInterceptorOptions` interface configures options for removing a request interceptor.
-
-### port
-
-- **Description**: Main process port
-- **Default value**: `5270`
+```ts
+{
+ id: 'fixture',
+ match: {
+ url: 'https://api.example.test/*', // exact or `*` glob
+ method: 'POST',
+ headers: { 'x-test-mode': 'mock' }
+ },
+ response: {
+ status: 201,
+ statusText: 'Created',
+ headers: { 'content-type': 'application/json' },
+ body: '{"mocked":true}',
+ // bodyBase64: 'AAEC/w==', // binary alternative
+ delayMs: 10
+ }
+}
+```
-## RemoveResponseInterceptorOptions
+Auto selects Legacy when rules exist. Forced Native fails synchronously with
+`NND_NATIVE_MOCK_CONFLICT`.
-The `RemoveResponseInterceptorOptions` interface configures options for removing a response interceptor.
+## Deprecated v1 fields
-### port
+- `adapter` → `mode`
+- `requiredFeatures` → `requiredCapabilities`
+- `autoOpenDevtool` → `devtools.open`
+- top-level `serverPort` → `legacy.serverPort`
+- top-level `intercept` → `legacy.intercept`
+- `port` → remove; child IPC replaced the old 5270 WebSocket bridge
-- **Description**: Main process port
-- **Default value**: `5270`
\ No newline at end of file
+Compatibility fields still work with diagnostics during the v2 migration.
diff --git a/apps/web/docs/zh/get-started.md b/apps/web/docs/zh/get-started.md
index 94a95ce..3212fce 100644
--- a/apps/web/docs/zh/get-started.md
+++ b/apps/web/docs/zh/get-started.md
@@ -2,6 +2,8 @@
Node Network Devtools 是一款软集成了 Chrome Devtools 的网络调试工具。它提供了相当于浏览器的网络调试体验,并且非常容易接入。
+要求 Node.js `>=18.18`。
+
## 安装
::: code-tabs
@@ -28,7 +30,19 @@ npm i -D node-network-devtools
## 使用
-支持 esm 和 commonjs 标准的nodejs程序,只需要在入口文件中引入并调用`register`方法即可。
+推荐使用 CLI 零代码接入。`--open` 会在 Inspector 或 Legacy target 就绪后打开
+准确的调试地址;不传该参数时,CLI 只输出 target,浏览器进程仍由你管理。
+
+```bash
+npx nnd dev --open src/app.js
+npx nnd dev --no-wait --runner tsx src/app.ts -- --port 3000
+npx nnd doctor --json
+```
+
+Native/Auto 默认会先暂停应用,避免 DevTools 前端错过启动阶段的请求;希望应用立即
+运行时可添加 `--no-wait`。
+
+ESM 和 CommonJS 应用也可以使用库 API:
::: code-tabs
@@ -36,16 +50,36 @@ npm i -D node-network-devtools
```typescript
import { register } from 'node-network-devtools'
-register()
+
+const registration = register({
+ mode: 'auto',
+ devtools: { open: true }
+})
+
+const ready = await registration.ready
+console.log(ready.mode, ready.target.discoveryUrl)
+
+// 在应用退出流程中调用。
+await registration.dispose()
```
@tab javascript
```javascript
const { register } = require('node-network-devtools')
-register()
+
+const registration = register({
+ mode: 'legacy',
+ devtools: { open: true }
+})
+
+registration.ready.then(({ mode, target }) => {
+ console.log(mode, target.discoveryUrl)
+})
```
:::
-如果需要使用选项,可以前往 [选项](./options.md) 查看详细说明。
+浏览器打开行为需要显式启用,target 端口默认由操作系统分配。完整配置见
+[选项](./options.md) 和
+[v1 到 v2 迁移指南](https://github.com/GrinZero/node-network-devtools/blob/main/docs/v2-migration.md)。
diff --git a/apps/web/docs/zh/options.md b/apps/web/docs/zh/options.md
index 7095a23..75bb8bc 100644
--- a/apps/web/docs/zh/options.md
+++ b/apps/web/docs/zh/options.md
@@ -1,137 +1,110 @@
-# 选项
+# 配置选项
-## RegisterOptions
+v2 API 将后端选择、Inspector target、前端打开方式、Legacy hook 和会话记录分开配置。
-`RegisterOptions` 接口用于配置网络调试器的注册选项。以下是各个选项的详细说明及其默认值。
-
-### 示例
-
-以下是一个使用 `RegisterOptions` 的示例:
-
-```typescript
-import { RegisterOptions, register } from 'node-network-devtools'
+```ts
+import { register, type RegisterOptions } from 'node-network-devtools'
const options: RegisterOptions = {
- port: 5270,
- serverPort: 5271,
- autoOpenDevtool: true,
- intercept: {
- fetch: true,
- normal: true
+ mode: 'auto',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false },
+ session: { directory: '.nnd/sessions/local', har: true },
+ legacy: {
+ serverPort: 0,
+ intercept: { normal: true, fetch: true, undici: { fetch: false } }
}
}
-// 使用 options 进行网络调试器的注册
-register(options)
+const registration = register(options)
+await registration.ready
+await registration.dispose()
```
-### port
-
-- **描述**: 主进程端口
-- **默认值**: `5270`
-
-### serverPort
-
-- **描述**: CDP 服务器端口,用于 Devtool
-- **链接**: [devtools://devtools/bundled/inspector.html?ws=127.0.0.1:${serverPort}](devtools://devtools/bundled/inspector.html?ws=127.0.0.1:${serverPort})
-- **默认值**: `5271`
-
-### autoOpenDevtool
-
-- **描述**: 是否自动打开 Devtool
-- **默认值**: `true`
-
-### intercept
-
-- **描述**: 用于拦截不同类型请求的选项。
- 如果某个属性设置为 `false`,则不会拦截该特定类型的请求。
- 默认情况下,如果未明确设置,则全部拦截。
-
-#### intercept.fetch
-
-- **描述**: 是否拦截 `fetch` 请求。
-- **默认值**: `true`
-
-#### intercept.normal
-
-- **描述**: 是否拦截 `http/https` 请求。
-- **默认值**: `true`
+## `mode`
-#### intercept.undici
+- `auto`(默认):优先选择经过验证的 Native 实现;切换到 Legacy 时会返回结构化原因。
+- `native`:要求 Node 的实验性 Network Inspector 和所有指定能力,绝不静默回退。
+- `legacy`:安装项目自己的捕获 hook,并使用项目提供的标准 CDP target。
-- **描述**: `undici` 请求的拦截选项。设置为 `false` 以禁用所有 `undici` 拦截。否则,请配置特定的 `undici` 拦截选项。
-- **默认值**: `false`
-- **选项**:
- - `fetch`: 是否拦截 `undici` 的 `fetch` 请求。默认为 `false`。
- - `normal`: 是否拦截 `undici` 的普通请求。默认为 `false`。
+## `requiredCapabilities`
-## ConnectOptions
+数组成员可以是 `http`、`https`、`fetch`、`http2`、`responseBody`、
+`requestBody`、`websocketLifecycle`、`websocketFrames`、`sseMessages` 或
+`initiator`。如果后端缺少任一必需能力,选择过程会失败或回退。
-`ConnectOptions` 接口配置连接到网络调试器的选项。
+## `inspector`
-### port
+- `host`:Inspector 绑定地址,默认为 `127.0.0.1`。
+- `port`:Inspector 端口,默认为 `0`,即交给操作系统分配。
-- **描述**: 主进程端口
-- **默认值**: `5270`
+## `devtools`
-## UnregisterOptions
+- `open`:库注册完成后是否显式打开返回的 target,默认为 `false`。
-`UnregisterOptions` 接口配置取消注册网络调试器的选项。
+后端不会持有或结束由此打开的浏览器进程。
-### port
+## `session`
-- **描述**: 主进程端口
-- **默认值**: `5270`
+- `directory`:`manifest.json`、`events.ndjson` 和 `bodies/` 的准确输出目录;
+ 该目录不能已经包含 Session 产物。
+- `bodyCommandTimeoutMs`:每次 `Network.getResponseBody` 命令的可选正数超时。
+- `har`:`true` 表示在释放时将 `session.har` 写入 Session 目录;字符串表示
+ 指定路径;省略或设为 `false` 则不自动导出。
-## SendMessageOptions
+记录器会先于后端关闭,以便未完成的 body 命令能够正常结束。
-`SendMessageOptions` 接口配置发送消息的选项。
+## `legacy`
-### port
+### `serverPort`
-- **描述**: 主进程端口
-- **默认值**: `5270`
+Legacy CDP discovery/WebSocket target 的端口,默认为 `0`。应用与桥接子进程之间
+已经改用 IPC,不再占用旧的 TCP 端口。
-## SetRequestInterceptorOptions
+### `intercept`
-`SetRequestInterceptorOptions` 接口配置设置请求拦截器的选项。
+- `normal`:捕获 `http.request/get` 和 `https.request/get`,默认开启。
+- `fetch`:捕获全局 Fetch,默认开启。
+- `undici.fetch`:捕获单独安装的 Undici Fetch,默认关闭,需要显式选择。
+ `undici@^6` 是 package peer,确保 hook 使用应用的同一个模块实例;关闭 peer
+ 自动安装时需要手动安装。
-### port
+关闭的传输会在该 Legacy 会话中报告为不可用能力。
-- **描述**: 主进程端口
-- **默认值**: `5270`
+### `mock`
-### request
+按顺序匹配的 Legacy 专用请求/响应规则:
-- **描述**: 一个用于拦截和修改传出请求的函数。
-
-## SetResponseInterceptorOptions
-
-`SetResponseInterceptorOptions` 接口配置设置响应拦截器的选项。
-
-### port
-
-- **描述**: 主进程端口
-- **默认值**: `5270`
-
-### response
-
-- **描述**: 一个用于拦截和修改传入响应的函数。
-
-## RemoveRequestInterceptorOptions
-
-`RemoveRequestInterceptorOptions` 接口配置移除请求拦截器的选项。
-
-### port
-
-- **描述**: 主进程端口
-- **默认值**: `5270`
+```ts
+{
+ id: 'fixture',
+ match: {
+ url: 'https://api.example.test/*', // 精确地址或 `*` glob
+ method: 'POST',
+ headers: { 'x-test-mode': 'mock' }
+ },
+ response: {
+ status: 201,
+ statusText: 'Created',
+ headers: { 'content-type': 'application/json' },
+ body: '{"mocked":true}',
+ // bodyBase64: 'AAEC/w==', // 二进制响应可用此字段
+ delayMs: 10
+ }
+}
+```
-## RemoveResponseInterceptorOptions
+配置规则后,Auto 会选择 Legacy。强制 Native 会同步抛出
+`NND_NATIVE_MOCK_CONFLICT`。
-`RemoveResponseInterceptorOptions` 接口配置移除响应拦截器的选项。
+## 已弃用的 v1 字段
-### port
+- `adapter` → `mode`
+- `requiredFeatures` → `requiredCapabilities`
+- `autoOpenDevtool` → `devtools.open`
+- 顶层 `serverPort` → `legacy.serverPort`
+- 顶层 `intercept` → `legacy.intercept`
+- `port` → 删除;子进程 IPC 已取代旧的 5270 WebSocket bridge
-- **描述**: 主进程端口
-- **默认值**: `5270`
\ No newline at end of file
+迁移到 v2 期间,这些兼容字段仍然可用,并会产生诊断信息。
diff --git a/docs/test-evidence/v2/pr-64/README.md b/docs/test-evidence/v2/pr-64/README.md
new file mode 100644
index 0000000..a74f1ba
--- /dev/null
+++ b/docs/test-evidence/v2/pr-64/README.md
@@ -0,0 +1,186 @@
+# PR #64 manual acceptance evidence
+
+Result: **14/14 manual test cases passed**.
+
+This is the human-observable acceptance layer for the automated v2 suite. The
+run used the exact packed package from the tested product commit, an isolated
+consumer, real loopback traffic, the public CLI/runtime APIs, standard CDP
+discovery, and the official Chromium DevTools Network panel. The evidence set
+contains 26 privacy-reviewed screenshots, 17 structured artifacts, five
+reproduction harnesses, two test-only localhost certificate inputs, and a
+checksum manifest.
+
+## Evidence identity
+
+| Field | Value |
+| --------------------- | ------------------------------------------------------------------ |
+| Pull request | [#64](https://github.com/GrinZero/node-network-devtools/pull/64) |
+| Tested product commit | `449d47db89109e826eb0e7e0584777365eac3f9b` |
+| Installed package | `node-network-devtools@2.0.0` |
+| Packed files | 190 |
+| Tarball SHA-256 | `e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac` |
+| Harness SHA-256 | `46f81eb2930040a31f402cf4cb113758628eb72c9db27617babba1d3df31af27` |
+| Primary runtime | Node.js `v24.16.0`, `darwin-arm64` |
+| Compatibility probe | Node.js `v22.22.3`, `v24.16.0`, and `v26.8.1` |
+| Browser | Chrome for Testing `151.0.7922.34` |
+| Browser automation | `@playwright/cli 0.1.18`, engine `1.63.0-alpha-2026-08-05` |
+| Test date | 2026-08-28 (Asia/Shanghai) |
+
+The package was built and packed at the tested product commit, hashed, and
+installed into an otherwise empty consumer. The harness imports only the public
+exports from that installed tarball. The package identity is repeated in the
+[Native runtime artifact](../../../../output/playwright/pr-64/artifacts/native-runtime.json),
+[Legacy runtime artifact](../../../../output/playwright/pr-64/artifacts/legacy-runtime.json),
+and [manifest](../../../../output/playwright/pr-64/manifest.json).
+
+## Method and trust boundary
+
+- Playwright operated visible scenario controls and the official Chromium
+ DevTools frontend. Every click caused the isolated consumer process to issue
+ real outbound traffic; the screenshots are not a mock DevTools UI.
+- Computer Use was used for the exploratory integrated-terminal CLI pass. The
+ final repeatable CLI proof was rerun through a Playwright control page and
+ persisted as both a screenshot and structured JSON.
+- HTTP, HTTPS, Fetch, SSE, WebSocket, failed requests, mocks, and replay all used
+ local loopback servers. HTTPS used the retained test-only localhost
+ certificate; its client explicitly disabled trust verification for that
+ fixture only.
+- The harness fetched and asserted the complete `/json/list`, `/json/version`,
+ and `/json/protocol` contracts, including the Network domain. Raw discovery
+ screenshots containing a local file URL were deliberately excluded; the
+ privacy-safe capability page, version page, and structured response retain
+ the same protocol assertions.
+- Session manifests, raw CDP events, HAR files, replay summaries, CLI results,
+ capability-boundary assertions, and disposal probes are committed alongside
+ the screenshots. Only local filesystem prefixes were redacted; protocol
+ methods, statuses, bodies, counters, and lifecycle semantics were preserved.
+- [`checksums.sha256`](../../../../output/playwright/pr-64/checksums.sha256)
+ covers all 51 retained files in the evidence bundle other than
+ `.gitignore` and the checksum file itself. Screenshots satisfy the requested
+ visual-evidence option; no video is committed.
+
+## Test cases
+
+| ID | Manual action | Expected result | Observed result and evidence | Status |
+| ----- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
+| MT-01 | Run `nnd --version`, then run `nnd doctor --json` with Node network inspection enabled. | Version is `2.0.0`; doctor is healthy, has no missing requirement, and selects Native. | `version=2.0.0`, `ok=true`, `experimentalFlag=true`, `missingRequired=[]`, `selected=native`. [Screenshot](../../../../output/playwright/pr-64/screenshots/MT-01-cli-version-doctor-native.png) · [CLI JSON](../../../../output/playwright/pr-64/artifacts/cli-manual-results.json) | **PASS** |
+| MT-02 | Run public `nnd dev` in Native and Legacy modes, force Native with a mock rule, and run `nnd dev --open`. | Both modes inject the preload and expose a target; invalid Native + Mock fails; `--open` launches exactly the advertised target. | Six visible CLI cases passed. Native received inspection/import flags; Legacy received the import; conflict returned `NND_NATIVE_MOCK_CONFLICT`; the OS launch was redirected to an exact-target CDP verifier and occurred once. [Screenshot](../../../../output/playwright/pr-64/screenshots/MT-02-cli-zero-code-and-conflict.png) · [CLI JSON](../../../../output/playwright/pr-64/artifacts/cli-manual-results.json) | **PASS** |
+| MT-03 | Register the exact package in Native mode and inspect identity, public references, target, and capabilities. | Native is ready without fallback; public networking functions remain unpatched; only verified capabilities are advertised. | Native selected; original `fetch`, `http.request`, and `https.request` references were preserved. Node 24 correctly reports `http2=false`, `requestBody=false`, `websocketFrames=false`, and `sseMessages=false`. [Screenshot](../../../../output/playwright/pr-64/screenshots/MT-03-native-runtime-and-capabilities.png) · [Runtime JSON](../../../../output/playwright/pr-64/artifacts/native-runtime.json) | **PASS** |
+| MT-04 | Fetch the Native target's `/json/list`, `/json/version`, and `/json/protocol` endpoints. | One matching Node target is discoverable; version metadata is valid; the protocol exposes the Network domain. | Target count `1`, matching id, browser `node.js/v24.16.0`, protocol `1.1`, Network domain present with 6 commands and 8 events. [Capability/discovery screenshot](../../../../output/playwright/pr-64/screenshots/MT-03-native-runtime-and-capabilities.png) · [Version screenshot](../../../../output/playwright/pr-64/screenshots/MT-04-native-version.png) · [Structured contract/runtime JSON](../../../../output/playwright/pr-64/artifacts/native-runtime.json) | **PASS** |
+| MT-05 | Open official DevTools and trigger real HTTP GET, HTTPS GET, and Fetch POST requests; inspect list, headers, and response. | Each request appears once with correct status and metadata; response bodies are readable. | Requests returned `200`, `200`, and `201`; the Fetch headers carry the correlation token and its response echoes the manual value. Native does not claim request-body retrieval. [List](../../../../output/playwright/pr-64/screenshots/MT-05-native-devtools-network-list.png) · [Headers](../../../../output/playwright/pr-64/screenshots/MT-05-native-fetch-headers.png) · [Response](../../../../output/playwright/pr-64/screenshots/MT-05-native-fetch-response.png) · [Events](../../../../output/playwright/pr-64/artifacts/native-events.ndjson) | **PASS** |
+| MT-06 | Reload official Native DevTools, reconnect to the same target, and trigger another GET. | The frontend reconnects and captures post-reload traffic without replaying old rows as new requests. | A new `native-http-get-08` request appears with status `200` after reload/reconnect. [Screenshot](../../../../output/playwright/pr-64/screenshots/MT-06-native-devtools-reload-reconnect.png) · [Session manifest](../../../../output/playwright/pr-64/artifacts/native-session-manifest.json) | **PASS** |
+| MT-07 | Register in Auto mode with Legacy mock rules and inspect backend selection and capabilities. | Auto selects Legacy with an explicit reason and exposes its larger detail-level capability set. | Backend `legacy`; reason `NND_AUTO_LEGACY_MOCK_REQUIRED`; request-body, WebSocket-frame, and SSE-message capture enabled. [Screenshot](../../../../output/playwright/pr-64/screenshots/MT-07-legacy-runtime-and-capabilities.png) · [Runtime JSON](../../../../output/playwright/pr-64/artifacts/legacy-runtime.json) | **PASS** |
+| MT-08 | Fetch the Legacy target's `/json/list`, `/json/version`, and `/json/protocol` endpoints. | One matching project-owned CDP target is discoverable on loopback and the protocol exposes Network. | Target count `1`, matching id, browser `node-network-devtools/2`, protocol `1.3`, Network domain present with 7 commands and 12 events. [Capability/discovery screenshot](../../../../output/playwright/pr-64/screenshots/MT-07-legacy-runtime-and-capabilities.png) · [Version screenshot](../../../../output/playwright/pr-64/screenshots/MT-08-legacy-version.png) · [Structured contract/runtime JSON](../../../../output/playwright/pr-64/artifacts/legacy-runtime.json) | **PASS** |
+| MT-09 | In official DevTools, trigger Legacy HTTP GET, HTTPS GET, and Fetch POST; inspect list, Payload, and Response. | Statuses are correct and both POST request body and response body are available. | Fetch is `201`; Payload contains `manual-request-body:legacy-fetch-post-03`; Response contains the matching echo. [List](../../../../output/playwright/pr-64/screenshots/MT-09-legacy-devtools-network-list.png) · [Payload](../../../../output/playwright/pr-64/screenshots/MT-09-legacy-fetch-payload.png) · [Response](../../../../output/playwright/pr-64/screenshots/MT-09-legacy-fetch-response.png) · [Events](../../../../output/playwright/pr-64/artifacts/legacy-events.ndjson) | **PASS** |
+| MT-10 | Trigger matching Legacy mock rules through `http.request` and Fetch; inspect status, body, headers, and origin counter. | HTTP returns `207`, Fetch returns `202`, and neither request reaches the origin server. | Both responses traverse the normal Network path; mock bodies/headers are visible and `originLeakCount=0`. [HTTP](../../../../output/playwright/pr-64/screenshots/MT-10-legacy-mock-http-response.png) · [Fetch](../../../../output/playwright/pr-64/screenshots/MT-10-legacy-mock-fetch-response.png) · [Summary](../../../../output/playwright/pr-64/artifacts/legacy-finalize-summary.json) | **PASS** |
+| MT-11 | Trigger SSE and WebSocket exchanges on both backends and inspect the visible detail tabs and protocol counters. | Native reports only what upstream provides; Legacy exposes SSE messages and sent/received WebSocket frames. | Native: one create/close pair, zero frames, SSE request visible with zero message events. Legacy: two SSE messages plus 2 sent and 2 received frames. [Native lifecycle](../../../../output/playwright/pr-64/screenshots/MT-11-native-websocket-lifecycle.png) · [Legacy SSE](../../../../output/playwright/pr-64/screenshots/MT-11-legacy-sse-eventstream.png) · [Legacy WebSocket](../../../../output/playwright/pr-64/screenshots/MT-11-legacy-websocket-messages.png) · [Native assertion](../../../../output/playwright/pr-64/artifacts/native-finalize-summary.json) · [Legacy assertion](../../../../output/playwright/pr-64/artifacts/legacy-finalize-summary.json) | **PASS** |
+| MT-12 | Finalize both recordings; inspect Session, HAR, trace correlation, library replay, and public CLI replay in dry/real modes. | Manifests complete without body errors; HAR is coherent; explicit trace context survives; dry and real replay both pass. | Native replay `5/5 + 5/5`; Legacy replay `6/6 + 6/6`; public CLI replay `2/2 + 2/2` and preserved the POST body; trace headers were preserved only on the explicitly traced request. [Native](../../../../output/playwright/pr-64/screenshots/MT-12-native-session-har-replay-trace.png) · [Legacy](../../../../output/playwright/pr-64/screenshots/MT-12-legacy-session-har-replay-trace.png) · [CLI](../../../../output/playwright/pr-64/artifacts/cli-manual-results.json) · [Native summary](../../../../output/playwright/pr-64/artifacts/native-finalize-summary.json) · [Legacy summary](../../../../output/playwright/pr-64/artifacts/legacy-finalize-summary.json) | **PASS** |
+| MT-13 | Trigger a connection-reset failure on both backends and probe Native HTTP/2 on Node 22, 24, and 26. | Failed requests emit exactly one terminal failure; HTTP/2 is advertised only where its complete lifecycle is verified. | Both backends emitted one `requestWillBeSent` and one `loadingFailed`, with no response/finished event. Node 22.22.3 completed HTTP/2 with body `h2-ok`; Node 24.16/26.8 reproduced the upstream Inspector `Missing dataLength` crash and correctly advertised `http2=false`. [Native failure](../../../../output/playwright/pr-64/screenshots/MT-13-native-failed-request.png) · [Legacy failure](../../../../output/playwright/pr-64/screenshots/MT-13-legacy-failed-request.png) · [Native boundary](../../../../output/playwright/pr-64/screenshots/MT-13-native-boundary-assertions.png) · [Legacy boundary](../../../../output/playwright/pr-64/screenshots/MT-13-legacy-boundary-assertions.png) · [HTTP/2 probe](../../../../output/playwright/pr-64/artifacts/native-http2-probe.json) | **PASS** |
+| MT-14 | Dispose each registration, then probe discovery HTTP and the target WebSocket. | State becomes disposed and both endpoints reject new connections. | Both summaries report `registrationState=disposed`, `discoveryClosed=true`, and `targetSocketClosed=true`. [Native](../../../../output/playwright/pr-64/screenshots/MT-14-native-dispose-cleanup.png) · [Legacy](../../../../output/playwright/pr-64/screenshots/MT-14-legacy-dispose-cleanup.png) · [Native JSON](../../../../output/playwright/pr-64/artifacts/native-dispose-summary.json) · [Legacy JSON](../../../../output/playwright/pr-64/artifacts/legacy-dispose-summary.json) | **PASS** |
+
+## Representative visual evidence
+
+
+CLI, runtime selection, and discovery
+
+
+
+
+
+
+
+
+
+
+
+
+Official DevTools request details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Recording, boundaries, failure, and cleanup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Structured evidence summary
+
+| Backend | Requests | Events | Bodies | Failed/body errors | HAR statuses | Replay dry/real | Dispose |
+| ------- | -------: | -----: | -----: | -----------------: | --------------------------------------------- | --------------- | ------------------------- |
+| Native | 9 | 24 | 6 | 1 / 0 | `200,200,201,200,0,200,0,0,200` | 5/5, 5/5 | discovery + socket closed |
+| Legacy | 12 | 54 | 10 | 1 / 0 | `200,200,200,200,200,201,200,0,200,0,207,202` | 6/6, 6/6 | discovery + socket closed |
+
+The zero-status HAR entries are intentionally incomplete SSE, WebSocket, or
+failed-request lifecycles. Their exact terminal-event assertions are recorded in
+the finalize summaries. There were no response-body retrieval errors and no
+mock-origin leaks.
+
+The HTTP/2 compatibility probe deserves separate emphasis. A non-empty h2c
+response completed `requestWillBeSent -> responseReceived -> loadingFinished`
+on Node 22.22.3. The same exact package and request reproduced an upstream
+`node:internal/inspector/network_http2` crash on Node 24.16.0 and 26.8.1.
+Consequently, commit `449d47d` changed the public capability matrix from an
+open-ended minimum-version claim to the verified Node 22.20+ 22.x line. This is
+consistent with Node's still-open
+[network-inspection stabilization tracker](https://github.com/nodejs/node/issues/53946)
+and avoids promising behavior that the runtime cannot safely deliver.
+
+## Reproduction
+
+The retained harnesses are
+[`manual-evidence-server.mjs`](../../../../output/playwright/pr-64/manual-evidence-server.mjs),
+[`manual-cli-evidence-server.mjs`](../../../../output/playwright/pr-64/manual-cli-evidence-server.mjs),
+[`manual-cli-case.sh`](../../../../output/playwright/pr-64/manual-cli-case.sh),
+[`manual-native-mock-conflict.mjs`](../../../../output/playwright/pr-64/manual-native-mock-conflict.mjs),
+and
+[`manual-native-http2-probe.mjs`](../../../../output/playwright/pr-64/manual-native-http2-probe.mjs).
+
+To reproduce:
+
+1. Check out product commit `449d47db89109e826eb0e7e0584777365eac3f9b`,
+ install from the lockfile, build, and pack `packages/network-debugger`.
+2. Verify the tarball SHA-256 above, then install it into an empty consumer.
+3. Set `NND_MANUAL_CONSUMER_ROOT`, `NND_MANUAL_PRODUCT_COMMIT`, and
+ `NND_MANUAL_TARBALL_SHA256`. Start the Native harness with
+ `--experimental-network-inspection`, or start the Legacy harness normally.
+4. Open the printed control URL. Click one scenario at a time and inspect the
+ printed official DevTools URL.
+5. Run the CLI evidence server's six visible cases and the three-version HTTP/2
+ probe. Compare all retained files with `checksums.sha256`.
+
+## Scope
+
+- All business traffic was loopback-only. Public-network and production
+ endpoints were intentionally out of scope.
+- Native limitations are visible results, not simulated features. On the
+ primary Node 24 run, request bodies, WebSocket frames, SSE messages, and
+ HTTP/2 are not advertised. Those detail-level claims are tested on Legacy or
+ on the verified Node 22 HTTP/2 runtime.
+- This manual run covers macOS arm64. The PR quality workflow remains responsible
+ for the repeatable Linux, macOS, Windows, and supported-Node automation.
diff --git a/docs/v2-implementation-plan.md b/docs/v2-implementation-plan.md
new file mode 100644
index 0000000..0605822
--- /dev/null
+++ b/docs/v2-implementation-plan.md
@@ -0,0 +1,429 @@
+# Node Network Devtools v2 implementation plan
+
+Status: complete
+
+Started: 2026-08-28
+
+Owner: repository maintainers and Codex implementation session
+
+This document is the authoritative implementation and completion plan for the v2
+architecture. A phase is complete only when its code, tests, documentation, and
+acceptance evidence are all present in the current worktree. A passing subset of
+tests is not sufficient to mark the overall plan complete.
+
+## Goals
+
+1. Make `NodeNativeAdapter` a first-class backend from the first implementation
+ phase.
+2. Replace mocked "CDP correctness" tests with real protocol end-to-end tests.
+3. Separate network capture, CDP target creation, and frontend launching.
+4. Stop coupling the debug server lifecycle to a Chrome process.
+5. Preserve a Legacy backend for capabilities missing from Node's native network
+ inspector.
+6. Improve zero-code setup, diagnostics, configuration, watch-mode behavior, and
+ package-consumer verification.
+7. Add session recording, HAR export, replay, Legacy-only mocking, and trace
+ correlation after the runtime and connection layers are stable.
+
+## Explicit non-goals for this plan
+
+- Security hardening as a dedicated workstream.
+- Incoming HTTP/server request inspection.
+- A custom DevTools frontend.
+- Bun or Deno support.
+- Native/Legacy hybrid capture for a single session.
+- Pretending that Node Native capabilities exist when the selected Node release
+ does not provide them.
+
+## Architectural decisions
+
+### One complete backend per session
+
+`NodeNativeAdapter` and `LegacyAdapter` are mutually exclusive complete
+backends. Native uses Node's own capture, CDP implementation, and Inspector
+target. Legacy uses the existing monkey-patch capture and a project-owned CDP
+bridge. They must never emit the same request in one session.
+
+```text
+register / preload / CLI
+ |
+ v
+ RuntimeController
+ |- ConfigResolver
+ |- AdapterSelector
+ `- RegistrationHandle
+ |
+ +-----+-----------+
+ | |
+ v v
+NodeNativeAdapter LegacyAdapter
+ | |
+Node Inspector Legacy capture + bridge
+ | |
+ +--------+--------+
+ v
+ DevtoolsTarget
+ | |
+ v v
+ optional frontend optional ProtocolTap/session pipeline
+```
+
+### Connection ownership
+
+The backend owns a debuggable target, not a browser. Core runtime code must not
+start a Chrome remote-debugging server, send `Page.navigate`, retain a browser
+process handle, or kill a browser during disposal.
+
+### Capability-driven selection
+
+Selection is based on an explicit, versioned capability matrix verified by E2E
+tests. Forced Native fails if requirements are not met. Only Auto may fall back
+to Legacy, and it must expose a structured fallback reason.
+
+## Public API target
+
+```ts
+const registration = register({
+ mode: 'auto',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false }
+})
+
+const ready = await registration.ready
+console.log(ready.mode, ready.target, ready.capabilities)
+
+await registration.openDevtools()
+await registration.dispose()
+```
+
+During the compatibility period the returned handle remains callable, so the
+existing `const unregister = register(); unregister()` form still works.
+
+Mode semantics:
+
+- `native`: require the experimental flag and required runtime capabilities;
+ never silently fall back.
+- `legacy`: always use project-owned capture and CDP bridge.
+- `auto`: prefer a proven Native baseline, otherwise use Legacy with a visible
+ fallback diagnostic.
+
+## Phase 1: Native backend and real Native CDP E2E
+
+### Deliverables
+
+- [x] Define runtime, adapter, target, session, diagnostic, and capability types.
+- [x] Implement `AdapterSelector`.
+- [x] Implement `NodeNativeAdapter` using `node:inspector`.
+- [x] Reuse an existing Inspector endpoint without taking ownership of it.
+- [x] Open an Inspector on an OS-assigned port when the adapter owns the target.
+- [x] Read the canonical target descriptor from Node's `/json/list` endpoint.
+- [x] Return a backward-compatible observable registration handle.
+- [x] Ensure Native never patches `fetch`, `http.request`, or `https.request`.
+- [x] Ensure Native never forks or starts the Legacy 5270/5271 services.
+- [x] Add real Native protocol E2E tests against the built package.
+- [x] Add a CI quality workflow that runs the first Native E2E gate.
+
+### Native E2E baseline
+
+- [x] `Network.enable` returns a response with the same command id.
+- [x] A real HTTP request emits a valid lifecycle.
+- [x] A real Fetch request emits a valid lifecycle.
+- [x] A failed request emits `Network.loadingFailed` only.
+- [x] `Network.getResponseBody` returns the actual fixture body where supported.
+- [x] `Runtime.evaluate('process.pid')` proves the client is attached to the
+ target process.
+- [x] Initiator data points to a real fixture source location where supported.
+- [x] Explicit Native without the required flag fails with an actionable error.
+- [x] Auto fallback returns a structured reason.
+- [x] Disposal closes only an Inspector created by the adapter.
+
+## Phase 2: target connection and developer experience
+
+### Deliverables
+
+- [x] Add the `nnd` CLI.
+- [x] Add a side-effect preload export.
+- [x] Add `nnd dev`, `nnd doctor`, and `nnd doctor --json`.
+- [x] Launch Native targets with the experimental network-inspection flag.
+- [x] Support wait-for-first-frontend and no-wait startup modes.
+- [x] Add explicit frontend launching through a separate `FrontendLauncher`.
+- [x] Remove the Chrome port 9333 polling and `Page.navigate` implementation.
+- [x] Stop retaining or killing a Chrome process.
+- [x] Add hosted official DevTools frontend smoke tests.
+- [x] Add CJS, ESM, tsx, Nest compiled, and Node watch fixtures.
+
+### Developer-experience acceptance
+
+- [x] `nnd dev app.js` starts a Native-capable target with no source edit.
+- [x] `nnd dev --open app.js` explicitly opens the returned target URL.
+- [x] Library usage does not open a browser by default.
+- [x] Ready output contains mode, target, capabilities, and fallback reason.
+- [x] Diagnostics use stable codes and provide actionable hints.
+- [x] Repeated registration is idempotent for equal configuration and rejects
+ conflicting configuration.
+
+## Phase 3: Legacy adapter and real Legacy CDP E2E
+
+### Deliverables
+
+- [x] Move current capture behavior behind `LegacyAdapter`.
+- [x] Complete Auto/Native/Legacy selection and old-option migration.
+- [x] Mark current request hooks as Legacy-only.
+- [x] Add real Legacy protocol E2E with no mocked server or manufactured CDP
+ events.
+- [x] Respond to all command ids with a result or a standard CDP error.
+- [x] Implement correct `Network.loadingFailed` behavior.
+- [x] Preserve HTTP, Fetch, binary, SSE, WebSocket, and initiator behavior.
+
+### Legacy protocol baseline
+
+- [x] HTTP GET and Fetch POST.
+- [x] Text, gzip, and binary response bodies.
+- [x] Redirect, abort, reset, and timeout behavior.
+- [x] SSE event name, id, data, and ordering.
+- [x] WebSocket handshake, text frame, binary frame, and close.
+- [x] Concurrent requests use stable, distinct request ids.
+- [x] CJS and ESM package consumers.
+
+## Phase 4: Legacy transport and discovery
+
+### Deliverables
+
+- [x] Replace the application-to-fork 5270 WebSocket with child-process IPC.
+- [x] Remove the lock-file and WebSocket health-ping mechanisms.
+- [x] Use a single HTTP server for Legacy target discovery and CDP WebSocket
+ upgrade.
+- [x] Implement `/json/list`, `/json/version`, and `/json/protocol`.
+- [x] Default to port `0`; never probe a free port before binding.
+- [x] Support multiple clients and DevTools refresh/reconnect.
+- [x] Ensure abnormal bridge exits produce bounded recovery and visible status.
+- [x] Ensure disposal leaves no port, timer, child, or pending promise behind.
+
+## Phase 5: compatibility and release gates
+
+### Deliverables
+
+- [x] Maintain a capability matrix backed by tests rather than runtime skipping.
+- [x] Add Node 18/20/22/24/26 runtime coverage as appropriate per adapter.
+- [x] Run mandatory Ubuntu protocol E2E on pull requests.
+- [x] Run Windows and macOS adapter smoke tests.
+- [x] Run a complete OS/runtime matrix nightly.
+- [x] Build and `npm pack` once, then test the published artifact as CJS and ESM
+ consumers.
+- [x] Make npm publishing depend on the same reusable quality workflow.
+- [x] Document Native/Legacy differences and migration from old options.
+
+## Phase 6: session and debugging enhancements
+
+### Deliverables
+
+- [x] Add a common protocol event journal/tap for both backends.
+- [x] Store sessions as `manifest.json`, `events.ndjson`, and external body files.
+- [x] Export valid HAR with matching text and binary bodies.
+- [x] Replay requests from a session or HAR, including dry-run mode.
+- [x] Implement request/response mocking for Legacy only.
+- [x] Reject Native plus Mock as an explicit capability conflict.
+- [x] Correlate existing `traceparent` values without injecting tracing by
+ default.
+- [x] Add real-network E2E for Session, HAR, Replay, Mock, and Trace.
+
+## E2E architecture
+
+Protocol E2E uses a thin raw WebSocket CDP client for both backends. It must
+spawn a real packaged consumer, connect to the real endpoint, trigger a real
+loopback request, and assert observed protocol invariants. It must not import a
+backend plugin directly.
+
+The fixture controller communicates with target applications over process IPC so
+test-control traffic does not pollute Network events. Each scenario uses a unique
+URL/query token and waits on explicit events instead of fixed sleeps.
+
+Frontend smoke tests use the official DevTools frontend bundled with the exact
+Playwright Chromium revision pinned in the lockfile. Chromium's loopback
+remote-debugging HTTP server serves those generated assets locally. The tests
+verify frontend connection, Network model population, response body retrieval,
+reconnect, console errors, page errors, and the absence of non-loopback
+frontend traffic. The source-only `chrome-devtools-frontend` npm tarball is not
+treated as a runnable frontend build.
+
+Required failure artifacts:
+
+- Complete CDP inbound/outbound NDJSON journal.
+- Target stdout and stderr.
+- Session descriptor and capability selection.
+- Playwright trace, screenshot, console, and page errors for frontend tests.
+
+## CI target matrix
+
+Pull-request minimum:
+
+| Job | Runtime and platform |
+| ---------------- | ------------------------------------------ |
+| Unit | Node 20/22/24/26 on Ubuntu |
+| Legacy runtime | Node 18/20/22/24/26 on Ubuntu |
+| Native runtime | supported Node 22/24/26 releases on Ubuntu |
+| Adapter OS smoke | Node 24 on Windows and macOS |
+| Frontend smoke | Node 24 on Ubuntu, Native and Legacy |
+| Pack consumer | CJS and ESM from the generated tarball |
+
+The runtime E2E controller should use `node:test` so evidence for Node 18 does not
+depend on the Vitest controller's own minimum runtime.
+
+## Suggested source layout
+
+```text
+packages/network-debugger/
+ src/
+ runtime/
+ adapters/
+ node-native/
+ legacy/
+ target/
+ diagnostics/
+ config/
+ session/
+ preload/
+ cli/
+ legacy-bridge/
+ test/e2e/
+ fixtures/
+ harness/
+ contracts/
+ protocol/
+ frontend/
+```
+
+## Final completion audit
+
+Before declaring the overall plan complete, inspect current evidence for every
+checkbox above and additionally prove:
+
+- [x] Native requests appear exactly once and never pass through Legacy code.
+- [x] Native does not change the references of supported network APIs.
+- [x] Legacy retains every previously documented capability.
+- [x] Both backends connect through actual standard targets.
+- [x] The project no longer owns a Chrome process.
+- [x] Protocol E2E contains no `vi.mock`, fake WebSocket server, or hand-written
+ `Network.*` event used as product evidence.
+- [x] Both protocol suites pass 50 consecutive runs without a failure or leaked
+ process.
+- [x] Windows and macOS smoke suites pass 20 consecutive runs.
+- [x] Frontend smoke passes 10 consecutive runs.
+- [x] Pull requests and publishing are blocked by the verified quality workflow.
+- [x] Documentation describes actual current capabilities, not intended ones.
+- [x] Every planned artifact exists in the packed npm output when required.
+
+## Progress log
+
+- 2026-08-28: Plan established from repository inspection, Node official
+ network-inspection capabilities, and a successful local Node 24.16 Native CDP
+ probe. Phase 1 started.
+- 2026-08-28: Phase 1 requirement audit passed. Evidence: 886 unit tests; clean
+ Vite build plus declaration emit; four real built-package Native CDP E2E
+ scenarios; ten consecutive E2E repetitions; owned/reused Inspector lifecycle
+ tests; public forced-Native and Auto-fallback tests; and a pull-request quality
+ workflow running unit, build, and Native E2E. Node 24.16 emits Native
+ `wallTime` in epoch milliseconds, so the Native-only E2E records and validates
+ that upstream deviation without transforming the direct Inspector protocol.
+- 2026-08-28: Phase 2 requirement audit passed. Evidence: 910 unit tests; clean
+ declaration and dual-runtime builds including ESM-only preload; four real
+ Native protocol scenarios; eight built-package CLI E2E scenarios covering
+ doctor, real `--open`/Inspector-wait resume, Auto-to-Legacy fallback, CJS,
+ ESM, tsx, watch, compiled Nest-style startup, signals, and orphan cleanup;
+ and an official pinned Chromium DevTools frontend smoke that populated its
+ real Network model, retrieved two response bodies across a reload/reconnect,
+ rejected non-loopback frontend traffic, and passed ten consecutive runs. The
+ CI quality job installs that pinned browser and runs all Phase 1/2 gates.
+- 2026-08-28: Phase 3 requirement audit passed. Legacy capture is isolated behind
+ its adapter and uses a real built-package CDP target. Evidence: eight protocol
+ scenarios pass for both CJS and ESM consumers and ten consecutive repetitions
+ completed 80/80; coverage includes HTTP, Fetch POST (including multi-chunk
+ request bodies), text/gzip/binary bodies, redirect/reset/timeout/abort, SSE,
+ WebSocket text/binary/close, 20 concurrent command ids, response-body retrieval,
+ standard CDP errors, and initiators. The official frontend also passed ten
+ consecutive Legacy runs and a combined Native/Legacy reconnect run.
+- 2026-08-28: Phase 4 requirement audit passed. The Legacy application bridge now
+ uses advanced-serialization child IPC and a single loopback HTTP/WebSocket target
+ on an OS-assigned port; the old 5270 transport, lock file, and health endpoint are
+ gone. Fifty-five target/discovery tests and 25 IPC lifecycle tests cover bounded
+ queues/history, multi-client isolation, stable target recovery, child flag
+ sanitization, and terminal diagnostics. A CLI shutdown race found by full E2E was
+ fixed by making the dedicated bridge close and exit on parent IPC disconnect;
+ the focused regression and the complete 8/8 CLI suite leave no matching child.
+- 2026-08-28: Phase 6 requirement audit passed. A backend-neutral real CDP
+ `ProtocolTap` records atomic manifests, NDJSON events, integrity-indexed external
+ bodies, and existing trace context; HAR 1.2 export preserves text/binary bodies,
+ while library and `nnd replay` APIs support Session/HAR dry-run and real replay.
+ Legacy-only HTTP/Fetch/Undici mocks traverse normal capture, Auto exposes a
+ structured reason, and forced Native fails with `NND_NATIVE_MOCK_CONFLICT`.
+ Twenty-two Session unit/integration tests and the full unit suite passed. The
+ built-package enhancement E2E passed 2/2 and then 20/20 across ten repetitions,
+ covering six real business requests/bodies, HAR, Replay, HTTP+Fetch Mock,
+ traceparent/tracestate, and zero origin leakage. That gate exposed and verified
+ the fix for an internal ProtocolTap self-observation recursion: the final
+ manifest contains exactly six requests, six bodies, zero failures, and no child
+ process leak.
+- 2026-08-28: Phase 5 requirement audit passed. The reusable quality workflow
+ builds and packs exactly once, then gates isolated CJS/ESM consumers, Ubuntu
+ Native/Legacy/CLI/enhancement protocol suites, the official frontend, Node
+ 20/22/24/26 unit lanes, Legacy Node 18/20/22/24/26, Native Node 22/24/26, and
+ Windows/macOS Node 24 20-round adapter smoke. Nightly adds Legacy on both OSes
+ for all five runtimes and Native for 22/24/26. Local exact-tarball evidence was
+ green across those available runtimes and macOS smoke; Node 22's incomplete
+ Native Fetch body caused the public cross-transport `responseBody` capability
+ to be conservatively disabled there. Publishing consumes the same SHA-256
+ verified artifact through the reusable gate with OIDC/provenance and checks
+ its release tag. That phase-5 candidate `node-network-devtools-2.0.0.tgz` installed in CJS
+ and ESM consumers, exposed all five exports/two bins, contained 187 files, and
+ passed `npm publish --dry-run` without performing a real publish.
+- 2026-08-28: Final local audit passed on the post-review tree. Three independent
+ reviews found and drove fixes for the Node `--import` minimum (`>=18.18`),
+ side-effect preload declarations, stale published-site instructions, missing
+ packed LICENSE, finite Legacy/`-e`/`-p` process lifetime, and negotiated
+ `permessage-deflate` capture. The final local gate passed 60 files/833 unit
+ tests, declaration plus CJS/ESM builds with no TypeScript diagnostic, all
+ Native/Legacy/enhancement/CLI/official-frontend E2E, and the nine-page
+ VuePress build. Native and Legacy protocol suites each passed 50 consecutive
+ final-tree runs; frontend, enhancement, and CLI suites each passed 10
+ consecutive runs; the watcher-specific regression passed 50; macOS packed
+ Native and Legacy adapters each passed 20 rounds; and no matching process
+ remained. The current exact `node-network-devtools-2.0.0.tgz` installs in CJS
+ and ESM consumers, passes packed Native/Legacy runtime tests plus publish
+ dry-run, contains 190 files including LICENSE, and has SHA-256
+ `dbd24df4d1ff4dda6ac545df0ef95c1287f0cace81d1e26fca08caa5667fce84`.
+- 2026-08-28: Final remote audit passed at commit
+ `231bcae02a9f05763a24c01d064127a71301895a`. GitHub Actions run
+ [33147572570](https://github.com/GrinZero/node-network-devtools/actions/runs/33147572570)
+ completed all 18 reusable-workflow jobs plus the top-level `Quality Gate`
+ successfully. The exact uploaded artifact
+ `node-network-devtools-tgz-33147572570-1` contains 190 files and has the same
+ SHA-256 recorded above. Its isolated packed-package controller passed 20
+ Native and 20 Legacy rounds on both `windows-latest` and `macos-latest`, with
+ discovery, target, disposal, and closed-endpoint assertions preserved. Active
+ repository ruleset
+ [21711512](https://github.com/GrinZero/node-network-devtools/rules/21711512)
+ targets `refs/heads/main`, strictly requires `Quality Gate` from GitHub Actions
+ integration `15368`, and has no bypass actors (`current_user_can_bypass` is
+ `never`). The release workflow cannot reach `publish` until the same reusable
+ quality workflow succeeds; it then downloads that workflow's sole artifact and
+ verifies its SHA-256 and release tag before publishing. No real npm publish was
+ performed as part of this implementation.
+- 2026-08-28: PR #64 manual acceptance audit passed 14/14 cases against exact
+ packed product commit `449d47db89109e826eb0e7e0584777365eac3f9b` and tarball
+ SHA-256
+ `e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac`.
+ Computer Use and Playwright exercised six public CLI cases, Native and Legacy
+ runtime selection, full standard discovery, official Chromium DevTools
+ request details, reload/reconnect, HTTPS, failed requests, Legacy mocks, SSE,
+ WebSocket lifecycle/frames, Session/HAR/Replay/Trace, capability boundaries,
+ and closed-endpoint disposal. The audit discovered that a non-empty HTTP/2
+ response crashes Node 24.16 and 26.8 inside
+ `node:internal/inspector/network_http2`; commit `449d47d` therefore replaced
+ the open-ended Native capability claim with the verified Node 22.20+ 22.x
+ range. The exact-package probe proves a complete lifecycle and body on Node
+ 22.22.3 while the affected/future majors correctly withhold the capability.
+ The reviewable matrix, 26 privacy-reviewed screenshots, 17 structured
+ artifacts, five reproduction harnesses, test TLS inputs, and SHA-256 manifest
+ are retained in the
+ [PR #64 manual evidence report](test-evidence/v2/pr-64/README.md).
diff --git a/docs/v2-migration.md b/docs/v2-migration.md
new file mode 100644
index 0000000..c37204a
--- /dev/null
+++ b/docs/v2-migration.md
@@ -0,0 +1,150 @@
+# Migrating to Node Network Devtools v2
+
+Version 2 separates runtime selection, network capture, CDP target ownership, and
+frontend launching. It keeps the callable v1 cleanup handle, but defaults and
+ownership rules are intentionally different.
+
+## Behavioral changes
+
+- `register()` no longer opens Chrome by default. Use `devtools.open: true`,
+ `registration.openDevtools()`, or `nnd dev --open`.
+- The project no longer starts, retains, navigates, or kills a Chrome process.
+- Legacy application-to-target traffic uses child-process IPC. Port 5270, its
+ WebSocket bridge, lock file, and health ping no longer exist.
+- Native and Legacy are complete, mutually exclusive backends. One request is
+ never captured by both in a single registration.
+- Inspector and Legacy target ports default to `0`, allowing the OS to bind an
+ available loopback port without a probe/bind race.
+- Auto prefers a proven Native runtime, then reports a structured reason when it
+ uses Legacy. Forced Native never silently falls back.
+- The `node-network-devtools/dev` subpath remains as a deprecated compatibility
+ alias, but now resolves to the same published `dist` entry as the package root.
+
+## Option mapping
+
+| v1 option | v2 option | Notes |
+| ------------------ | ---------------------- | ---------------------------------------------------- |
+| `adapter` | `mode` | `adapter` still works with a deprecation diagnostic. |
+| `requiredFeatures` | `requiredCapabilities` | Old name remains compatible. |
+| `autoOpenDevtool` | `devtools.open` | Default changed from implicit opening to `false`. |
+| `serverPort` | `legacy.serverPort` | Default is now `0`. |
+| `intercept` | `legacy.intercept` | Hooks are installed only when Legacy is selected. |
+| `port` | Remove | The old application bridge port is unused. |
+
+Before:
+
+```ts
+const unregister = register({
+ serverPort: 5271,
+ autoOpenDevtool: true,
+ intercept: { normal: true, fetch: true }
+})
+```
+
+After:
+
+```ts
+const registration = register({
+ mode: 'auto',
+ devtools: { open: true },
+ legacy: {
+ serverPort: 0,
+ intercept: { normal: true, fetch: true }
+ }
+})
+
+await registration.ready
+await registration.dispose()
+```
+
+The old `unregister()` call remains valid, but `await registration.dispose()` is
+preferred when teardown order matters.
+
+## Backend selection
+
+Use required capabilities to express behavior the application actually needs:
+
+```ts
+register({
+ mode: 'auto',
+ requiredCapabilities: ['requestBody', 'websocketFrames']
+})
+```
+
+Those requirements select Legacy today. Native capability values are derived
+from both the running Node version and available Inspector methods.
+
+Notable verified boundaries:
+
+- Native network inspection first appears behind the experimental flag in Node
+ 20.18 and 22.6.
+- Auto's proven Native baseline is Node 24.7 and newer.
+- Native HTTP/2 is explicitly allowlisted only on Node 22.20+ within the 22.x
+ line. A non-empty h2c lifecycle passed on Node 22.22.3, but consuming a
+ non-empty response with `setEncoding()` crashes the upstream experimental
+ Inspector on Node 24.16.0 and 26.8.1 with `Missing dataLength`. Other and
+ future majors remain false until independently verified; Legacy HTTP/2 is
+ also unsupported.
+- Node 22.22 can retrieve Native HTTP response bodies, but Fetch
+ `Network.getResponseBody` returns an empty body in the package E2E. Because the
+ public `responseBody` capability spans transports, v2 conservatively reports
+ it as false on Node 22 and true only on the verified Node 24+ baseline.
+- Native request bodies, WebSocket frames, SSE message parsing, and Mock are not
+ advertised. Select Legacy when these are required.
+
+Node 22 and 24 are LTS and Node 26 is Current at the time this plan was
+implemented. Node 18 and 20 are EOL; the package retains `>=18.18` compatibility
+and CI lanes so existing applications can migrate, but new deployments should
+use a maintained release.
+
+## Zero-code startup
+
+Instead of editing the application entry point, prefer:
+
+```bash
+nnd dev --open src/server.js
+nnd dev --runner tsx src/server.ts -- --port 3000
+nnd doctor --json
+```
+
+The CLI supplies the correct preloads and Native flag, supports wait/no-wait and
+watch behavior, forwards application arguments/signals, and prints the canonical
+target selected by the backend.
+
+## Session, HAR, Replay, and Mock
+
+Recording is backend-neutral:
+
+```ts
+const registration = register({
+ session: { directory: '.nnd/sessions/run-001', har: true }
+})
+await registration.ready
+// application traffic
+await registration.dispose()
+```
+
+Disposal closes the recorder and body commands, exports HAR when requested, then
+closes the backend even when recording fails. The exact output directory must
+not already contain `manifest.json` or `events.ndjson`.
+
+Replay can validate without I/O or issue real HTTP(S) requests:
+
+```bash
+nnd replay --dry-run --json .nnd/sessions/run-001
+nnd replay capture.har
+```
+
+Mock rules belong under `legacy.mock`. Configuring them in Auto selects Legacy
+with `NND_AUTO_LEGACY_MOCK_REQUIRED`; configuring them with forced Native throws
+`NND_NATIVE_MOCK_CONFLICT` before an adapter starts.
+
+## Release gates
+
+The v2 gate builds and packs once, then installs that exact tarball into isolated
+CJS and ESM consumers. Pull requests run mandatory Ubuntu protocol tests and a
+supported runtime matrix; Node 24 also runs the complete Native, Legacy, CLI,
+frontend, Session/HAR/Replay/Mock/Trace gates. Windows and macOS run adapter
+smoke loops, while the larger OS/runtime matrix runs nightly. Publishing calls
+the same reusable quality workflow and publishes the verified tarball with npm
+trusted publishing/provenance rather than rebuilding it.
diff --git a/output/playwright/pr-64/.gitignore b/output/playwright/pr-64/.gitignore
new file mode 100644
index 0000000..9a42538
--- /dev/null
+++ b/output/playwright/pr-64/.gitignore
@@ -0,0 +1,16 @@
+.runtime/
+.playwright/
+.playwright-cli/
+consumer/
+package/
+videos/
+*.log
+screenshots/MT-02-zero-code-native.png
+screenshots/MT-04-native-standard-discovery.png
+screenshots/MT-07-legacy-fallback-and-capabilities.png
+screenshots/MT-08-legacy-standard-discovery.png
+screenshots/MT-10-legacy-mock-fetch-no-origin-leak.png
+screenshots/MT-10-legacy-mock-http-no-origin-leak.png
+screenshots/MT-11-legacy-websocket-frames.png
+!manual-localhost-cert.pem
+!manual-localhost-key.pem
diff --git a/output/playwright/pr-64/artifacts/cli-manual-results.json b/output/playwright/pr-64/artifacts/cli-manual-results.json
new file mode 100644
index 0000000..2031f80
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/cli-manual-results.json
@@ -0,0 +1,291 @@
+{
+ "schemaVersion": 1,
+ "productCommit": "449d47db89109e826eb0e7e0584777365eac3f9b",
+ "tarballSha256": "e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac",
+ "cases": [
+ {
+ "id": "doctor",
+ "title": "CLI version + Native doctor",
+ "status": "PASS",
+ "command": "nnd --version && node --experimental-network-inspection /node_modules/.bin/nnd doctor --json",
+ "actual": {
+ "version": "2.0.0",
+ "schemaVersion": 1,
+ "ok": true,
+ "nodeVersion": "24.16.0",
+ "packageVersion": "2.0.0",
+ "experimentalFlag": true,
+ "missingRequired": [],
+ "selection": {
+ "requested": "auto",
+ "selected": "native"
+ },
+ "capabilities": {
+ "http": true,
+ "https": true,
+ "fetch": true,
+ "http2": false,
+ "responseBody": true,
+ "requestBody": false,
+ "websocketLifecycle": true,
+ "websocketFrames": false,
+ "sseMessages": false,
+ "initiator": true
+ }
+ }
+ },
+ {
+ "id": "native",
+ "title": "Zero-code nnd dev · NATIVE",
+ "status": "PASS",
+ "command": "nnd dev --no-wait --mode native /packages/network-debugger/test/e2e/cli/fixtures/probe.mjs",
+ "actual": {
+ "type": "fixture-ready",
+ "preloadInjected": true,
+ "mode": "native",
+ "execArgv": [
+ "--experimental-network-inspection",
+ "--inspect=127.0.0.1:0",
+ "--import=file:///node_modules/node-network-devtools/dist/register.mjs"
+ ],
+ "target": {
+ "id": "1c0c8e41-a808-4721-8aa6-6834bba2c010",
+ "type": "node",
+ "discoveryUrl": "http://127.0.0.1:60423/json/list",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:60423/1c0c8e41-a808-4721-8aa6-6834bba2c010"
+ },
+ "capabilities": {
+ "http": true,
+ "https": true,
+ "fetch": true,
+ "http2": false,
+ "responseBody": true,
+ "requestBody": false,
+ "websocketLifecycle": true,
+ "websocketFrames": false,
+ "sseMessages": false,
+ "initiator": true
+ }
+ }
+ },
+ {
+ "id": "legacy",
+ "title": "Zero-code nnd dev · LEGACY",
+ "status": "PASS",
+ "command": "nnd dev --no-wait --mode legacy /packages/network-debugger/test/e2e/cli/fixtures/probe.mjs",
+ "actual": {
+ "type": "fixture-ready",
+ "preloadInjected": true,
+ "mode": "legacy",
+ "execArgv": [
+ "--import=file:///node_modules/node-network-devtools/dist/register.mjs"
+ ],
+ "target": {
+ "id": "node-network-devtools-a426be27-20c0-4b1a-b552-be1414423c06",
+ "type": "node",
+ "discoveryUrl": "http://127.0.0.1:60436/json/list",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:60436/devtools/page/node-network-devtools-a426be27-20c0-4b1a-b552-be1414423c06"
+ },
+ "capabilities": {
+ "http": true,
+ "https": true,
+ "fetch": true,
+ "http2": false,
+ "responseBody": true,
+ "requestBody": true,
+ "websocketLifecycle": true,
+ "websocketFrames": true,
+ "sseMessages": true,
+ "initiator": true
+ }
+ }
+ },
+ {
+ "id": "conflict",
+ "title": "Forced Native + Mock conflict",
+ "status": "PASS",
+ "command": "node /manual-native-mock-conflict.mjs",
+ "actual": {
+ "name": "RuntimeRegistrationError",
+ "code": "NND_NATIVE_MOCK_CONFLICT",
+ "message": "Request/response mocking is available only with the Legacy backend.",
+ "details": {
+ "mode": "native",
+ "mockRuleCount": 1,
+ "hint": "Use mode: \"legacy\" or mode: \"auto\" when legacy.mock rules are configured."
+ }
+ }
+ },
+ {
+ "id": "replay",
+ "title": "Public nnd replay · dry + real",
+ "status": "PASS",
+ "command": "nnd replay --dry-run --json /artifacts/cli-replay-fixture.har && nnd replay --json /artifacts/cli-replay-fixture.har",
+ "actual": {
+ "dryRun": {
+ "dryRun": true,
+ "startedAt": "2026-08-28T08:00:40.372Z",
+ "completedAt": "2026-08-28T08:00:40.374Z",
+ "requests": [
+ {
+ "index": 0,
+ "requestId": "cli-replay-get",
+ "method": "GET",
+ "url": "http://127.0.0.1:60344/replay-get?case=cli-public-replay",
+ "headers": {}
+ },
+ {
+ "index": 1,
+ "requestId": "cli-replay-post",
+ "method": "POST",
+ "url": "http://127.0.0.1:60344/replay-post?case=cli-public-replay",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8"
+ },
+ "body": "cli-replay-request-body"
+ }
+ ],
+ "results": [
+ {
+ "request": {
+ "index": 0,
+ "requestId": "cli-replay-get",
+ "method": "GET",
+ "url": "http://127.0.0.1:60344/replay-get?case=cli-public-replay",
+ "headers": {}
+ },
+ "dryRun": true,
+ "ok": true,
+ "durationMs": 0
+ },
+ {
+ "request": {
+ "index": 1,
+ "requestId": "cli-replay-post",
+ "method": "POST",
+ "url": "http://127.0.0.1:60344/replay-post?case=cli-public-replay",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8"
+ },
+ "body": "cli-replay-request-body"
+ },
+ "dryRun": true,
+ "ok": true,
+ "durationMs": 0
+ }
+ ],
+ "succeeded": 2,
+ "failed": 0
+ },
+ "real": {
+ "dryRun": false,
+ "startedAt": "2026-08-28T08:00:40.450Z",
+ "completedAt": "2026-08-28T08:00:40.463Z",
+ "requests": [
+ {
+ "index": 0,
+ "requestId": "cli-replay-get",
+ "method": "GET",
+ "url": "http://127.0.0.1:60344/replay-get?case=cli-public-replay",
+ "headers": {}
+ },
+ {
+ "index": 1,
+ "requestId": "cli-replay-post",
+ "method": "POST",
+ "url": "http://127.0.0.1:60344/replay-post?case=cli-public-replay",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8"
+ },
+ "body": "cli-replay-request-body"
+ }
+ ],
+ "results": [
+ {
+ "request": {
+ "index": 0,
+ "requestId": "cli-replay-get",
+ "method": "GET",
+ "url": "http://127.0.0.1:60344/replay-get?case=cli-public-replay",
+ "headers": {}
+ },
+ "dryRun": false,
+ "ok": true,
+ "status": 200,
+ "statusText": "OK",
+ "responseHeaders": {
+ "connection": "keep-alive",
+ "content-type": "text/plain; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 08:00:40 GMT",
+ "keep-alive": "timeout=5",
+ "transfer-encoding": "chunked"
+ },
+ "durationMs": 8.241875000000007
+ },
+ {
+ "request": {
+ "index": 1,
+ "requestId": "cli-replay-post",
+ "method": "POST",
+ "url": "http://127.0.0.1:60344/replay-post?case=cli-public-replay",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8"
+ },
+ "body": "cli-replay-request-body"
+ },
+ "dryRun": false,
+ "ok": true,
+ "status": 201,
+ "statusText": "Created",
+ "responseHeaders": {
+ "connection": "keep-alive",
+ "content-type": "application/json; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 08:00:40 GMT",
+ "keep-alive": "timeout=5",
+ "transfer-encoding": "chunked"
+ },
+ "durationMs": 3.082583999999997
+ }
+ ],
+ "succeeded": 2,
+ "failed": 0
+ },
+ "originRequests": [
+ {
+ "method": "GET",
+ "url": "/replay-get?case=cli-public-replay",
+ "body": ""
+ },
+ {
+ "method": "POST",
+ "url": "/replay-post?case=cli-public-replay",
+ "body": "cli-replay-request-body"
+ }
+ ]
+ }
+ },
+ {
+ "id": "open",
+ "title": "nnd dev --open · authoritative target",
+ "status": "PASS",
+ "command": "nnd dev --open --mode native /packages/network-debugger/test/e2e/cli/fixtures/probe.mjs",
+ "actual": {
+ "preloadInjected": true,
+ "mode": "native",
+ "inspectWait": "--inspect-wait=127.0.0.1:0",
+ "target": {
+ "id": "7e52e8aa-c81d-45c8-ad04-26d18b69cbca",
+ "type": "node",
+ "discoveryUrl": "http://127.0.0.1:60572/json/list",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:60572/7e52e8aa-c81d-45c8-ad04-26d18b69cbca"
+ },
+ "openedExactlyOnce": true,
+ "openedFrontend": {
+ "frontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:60572/7e52e8aa-c81d-45c8-ad04-26d18b69cbca",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:60572/7e52e8aa-c81d-45c8-ad04-26d18b69cbca"
+ },
+ "launcherVerification": "OS browser spawn redirected to an exact-target CDP verifier"
+ }
+ }
+ ]
+}
diff --git a/output/playwright/pr-64/artifacts/cli-replay-fixture.har b/output/playwright/pr-64/artifacts/cli-replay-fixture.har
new file mode 100644
index 0000000..9b9b088
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/cli-replay-fixture.har
@@ -0,0 +1,63 @@
+{
+ "log": {
+ "version": "1.2",
+ "creator": {
+ "name": "PR #64 manual evidence",
+ "version": "1"
+ },
+ "pages": [],
+ "entries": [
+ {
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:60344/replay-get?case=cli-public-replay",
+ "httpVersion": "HTTP/1.1",
+ "headers": [],
+ "queryString": [
+ {
+ "name": "case",
+ "value": "cli-public-replay"
+ }
+ ],
+ "cookies": [],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {},
+ "cache": {},
+ "timings": {},
+ "_requestId": "cli-replay-get"
+ },
+ {
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:60344/replay-post?case=cli-public-replay",
+ "httpVersion": "HTTP/1.1",
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "case",
+ "value": "cli-public-replay"
+ }
+ ],
+ "cookies": [],
+ "headersSize": -1,
+ "bodySize": 22,
+ "postData": {
+ "mimeType": "text/plain; charset=utf-8",
+ "text": "cli-replay-request-body"
+ }
+ },
+ "response": {},
+ "cache": {},
+ "timings": {},
+ "_requestId": "cli-replay-post"
+ }
+ ]
+ }
+}
diff --git a/output/playwright/pr-64/artifacts/legacy-dispose-summary.json b/output/playwright/pr-64/artifacts/legacy-dispose-summary.json
new file mode 100644
index 0000000..7f17c58
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-dispose-summary.json
@@ -0,0 +1,5 @@
+{
+ "registrationState": "disposed",
+ "discoveryClosed": true,
+ "targetSocketClosed": true
+}
diff --git a/output/playwright/pr-64/artifacts/legacy-events.ndjson b/output/playwright/pr-64/artifacts/legacy-events.ndjson
new file mode 100644
index 0000000..ffec96f
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-events.ndjson
@@ -0,0 +1,54 @@
+{"schemaVersion":1,"sequence":1,"recordedAt":"2026-08-28T07:50:35.526Z","method":"Debugger.scriptParsed","params":{"url":"file:///manual-evidence-server.mjs","scriptLanguage":"JavaScript","embedderName":"file:///manual-evidence-server.mjs","scriptId":"1","sourceMapURL":"","hasSourceURL":false}}
+{"schemaVersion":1,"sequence":2,"recordedAt":"2026-08-28T07:50:35.528Z","method":"Network.requestWillBeSent","params":{"requestId":"0cc5f65a-40ac-4130-9be9-3f41d81ff929","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59430/json/list","request":{"url":"http://127.0.0.1:59430/json/list","method":"GET","headers":{},"initialPriority":"High","mixedContentType":"none"},"timestamp":0.009,"wallTime":1787903435.508,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":3,"functionName":"","lineNumber":391,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":5,"functionName":"process.processTicksAndRejections","lineNumber":104,"url":"node:internal/process/task_queues"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":3,"recordedAt":"2026-08-28T07:50:35.529Z","method":"Network.requestWillBeSent","params":{"requestId":"9405f647-edce-4fb1-a015-9e7b89878de5","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59430/json/version","request":{"url":"http://127.0.0.1:59430/json/version","method":"GET","headers":{},"initialPriority":"High","mixedContentType":"none"},"timestamp":0.011,"wallTime":1787903435.512,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":3,"functionName":"","lineNumber":392,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":5,"functionName":"process.processTicksAndRejections","lineNumber":104,"url":"node:internal/process/task_queues"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":4,"recordedAt":"2026-08-28T07:50:35.530Z","method":"Network.requestWillBeSent","params":{"requestId":"9e61ccb6-be11-43f9-8355-2d64a4cb6e60","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59430/json/protocol","request":{"url":"http://127.0.0.1:59430/json/protocol","method":"GET","headers":{},"initialPriority":"High","mixedContentType":"none"},"timestamp":0.011,"wallTime":1787903435.512,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":3,"functionName":"","lineNumber":393,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":5,"functionName":"process.processTicksAndRejections","lineNumber":104,"url":"node:internal/process/task_queues"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":5,"recordedAt":"2026-08-28T07:50:35.530Z","method":"Network.responseReceived","params":{"requestId":"0cc5f65a-40ac-4130-9be9-3f41d81ff929","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":0.017,"type":"Other","response":{"url":"http://127.0.0.1:59430/json/list","status":200,"statusText":"OK","headers":{"access-control-allow-origin":"*","cache-control":"no-store","connection":"keep-alive","content-type":"application/json; charset=utf-8","date":"Fri, 28 Aug 2026 07:50:35 GMT","keep-alive":"timeout=5","transfer-encoding":"chunked"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":6,"recordedAt":"2026-08-28T07:50:35.530Z","method":"Network.dataReceived","params":{"requestId":"0cc5f65a-40ac-4130-9be9-3f41d81ff929","timestamp":0.018,"dataLength":821,"encodedDataLength":821}}
+{"schemaVersion":1,"sequence":7,"recordedAt":"2026-08-28T07:50:35.531Z","method":"Network.loadingFinished","params":{"requestId":"0cc5f65a-40ac-4130-9be9-3f41d81ff929","timestamp":0.018,"encodedDataLength":821}}
+{"schemaVersion":1,"sequence":8,"recordedAt":"2026-08-28T07:50:35.536Z","method":"Network.responseReceived","params":{"requestId":"9405f647-edce-4fb1-a015-9e7b89878de5","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":0.019,"type":"Other","response":{"url":"http://127.0.0.1:59430/json/version","status":200,"statusText":"OK","headers":{"access-control-allow-origin":"*","cache-control":"no-store","connection":"keep-alive","content-type":"application/json; charset=utf-8","date":"Fri, 28 Aug 2026 07:50:35 GMT","keep-alive":"timeout=5","transfer-encoding":"chunked"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":9,"recordedAt":"2026-08-28T07:50:35.537Z","method":"Network.dataReceived","params":{"requestId":"9405f647-edce-4fb1-a015-9e7b89878de5","timestamp":0.019,"dataLength":248,"encodedDataLength":248}}
+{"schemaVersion":1,"sequence":10,"recordedAt":"2026-08-28T07:50:35.537Z","method":"Network.loadingFinished","params":{"requestId":"9405f647-edce-4fb1-a015-9e7b89878de5","timestamp":0.019,"encodedDataLength":248}}
+{"schemaVersion":1,"sequence":11,"recordedAt":"2026-08-28T07:50:35.538Z","method":"Network.responseReceived","params":{"requestId":"9e61ccb6-be11-43f9-8355-2d64a4cb6e60","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":0.019,"type":"Other","response":{"url":"http://127.0.0.1:59430/json/protocol","status":200,"statusText":"OK","headers":{"access-control-allow-origin":"*","cache-control":"no-store","connection":"keep-alive","content-type":"application/json; charset=utf-8","date":"Fri, 28 Aug 2026 07:50:35 GMT","keep-alive":"timeout=5","transfer-encoding":"chunked"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":12,"recordedAt":"2026-08-28T07:50:35.539Z","method":"Network.dataReceived","params":{"requestId":"9e61ccb6-be11-43f9-8355-2d64a4cb6e60","timestamp":0.019,"dataLength":1323,"encodedDataLength":1323}}
+{"schemaVersion":1,"sequence":13,"recordedAt":"2026-08-28T07:50:35.539Z","method":"Network.loadingFinished","params":{"requestId":"9e61ccb6-be11-43f9-8355-2d64a4cb6e60","timestamp":0.019,"encodedDataLength":1323}}
+{"schemaVersion":1,"sequence":14,"recordedAt":"2026-08-28T07:51:15.497Z","method":"Debugger.scriptParsed","params":{"url":"file:///manual-evidence-server.mjs","scriptLanguage":"JavaScript","embedderName":"file:///manual-evidence-server.mjs","scriptId":"1","sourceMapURL":"","hasSourceURL":false}}
+{"schemaVersion":1,"sequence":15,"recordedAt":"2026-08-28T07:51:54.793Z","method":"Network.requestWillBeSent","params":{"requestId":"58134218-34a0-4b54-a2ab-e1fd65450177","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/get?token=legacy-http-get-01","request":{"url":"http://127.0.0.1:59428/get?token=legacy-http-get-01","method":"GET","headers":{"host":"127.0.0.1:59428"},"initialPriority":"High","mixedContentType":"none"},"timestamp":79.291,"wallTime":1787903514.791,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":45,"functionName":"","lineNumber":114,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":0,"functionName":"new Promise","lineNumber":0,"url":""},{"columnNumber":10,"functionName":"httpRequest","lineNumber":112,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":22,"functionName":"runScenario","lineNumber":494,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":16,"recordedAt":"2026-08-28T07:51:54.796Z","method":"Network.responseReceived","params":{"requestId":"58134218-34a0-4b54-a2ab-e1fd65450177","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":79.292,"type":"Other","response":{"url":"http://127.0.0.1:59428/get?token=legacy-http-get-01","status":200,"statusText":"OK","headers":{"content-type":"text/plain; charset=utf-8","content-length":"38","x-manual-case":"legacy-http-get-01","date":"Fri, 28 Aug 2026 07:51:54 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"text/plain"}}}
+{"schemaVersion":1,"sequence":17,"recordedAt":"2026-08-28T07:51:54.797Z","method":"Network.dataReceived","params":{"requestId":"58134218-34a0-4b54-a2ab-e1fd65450177","timestamp":79.293,"dataLength":38,"encodedDataLength":38}}
+{"schemaVersion":1,"sequence":18,"recordedAt":"2026-08-28T07:51:54.798Z","method":"Network.loadingFinished","params":{"requestId":"58134218-34a0-4b54-a2ab-e1fd65450177","timestamp":79.293,"encodedDataLength":38}}
+{"schemaVersion":1,"sequence":19,"recordedAt":"2026-08-28T07:51:58.752Z","method":"Network.requestWillBeSent","params":{"requestId":"679ed0e5-ef59-4d2e-89ab-391343c82c31","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"https://127.0.0.1:59429/secure-get?token=legacy-https-get-02","request":{"url":"https://127.0.0.1:59429/secure-get?token=legacy-https-get-02","method":"GET","headers":{"host":"127.0.0.1:59429"},"initialPriority":"High","mixedContentType":"none"},"timestamp":83.251,"wallTime":1787903518.749,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":45,"functionName":"","lineNumber":114,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":0,"functionName":"new Promise","lineNumber":0,"url":""},{"columnNumber":10,"functionName":"httpRequest","lineNumber":112,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":22,"functionName":"runScenario","lineNumber":497,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":20,"recordedAt":"2026-08-28T07:51:58.760Z","method":"Network.responseReceived","params":{"requestId":"679ed0e5-ef59-4d2e-89ab-391343c82c31","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":83.257,"type":"Other","response":{"url":"https://127.0.0.1:59429/secure-get?token=legacy-https-get-02","status":200,"statusText":"OK","headers":{"content-type":"text/plain; charset=utf-8","content-length":"41","x-manual-case":"legacy-https-get-02","date":"Fri, 28 Aug 2026 07:51:58 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"text/plain"}}}
+{"schemaVersion":1,"sequence":21,"recordedAt":"2026-08-28T07:51:58.762Z","method":"Network.dataReceived","params":{"requestId":"679ed0e5-ef59-4d2e-89ab-391343c82c31","timestamp":83.257,"dataLength":41,"encodedDataLength":41}}
+{"schemaVersion":1,"sequence":22,"recordedAt":"2026-08-28T07:51:58.763Z","method":"Network.loadingFinished","params":{"requestId":"679ed0e5-ef59-4d2e-89ab-391343c82c31","timestamp":83.257,"encodedDataLength":41}}
+{"schemaVersion":1,"sequence":23,"recordedAt":"2026-08-28T07:52:02.894Z","method":"Network.requestWillBeSent","params":{"requestId":"3e47e0b7-987b-463c-aa0e-9765fc6d7e11","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/post?token=legacy-fetch-post-03","request":{"url":"http://127.0.0.1:59428/post?token=legacy-fetch-post-03","method":"POST","headers":{"content-type":"text/plain; charset=utf-8","x-manual-case":"legacy-fetch-post-03"},"initialPriority":"High","mixedContentType":"none","postData":"manual-request-body:legacy-fetch-post-03","hasPostData":true},"timestamp":87.391,"wallTime":1787903522.892,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":30,"functionName":"runScenario","lineNumber":502,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":24,"recordedAt":"2026-08-28T07:52:02.900Z","method":"Network.responseReceived","params":{"requestId":"3e47e0b7-987b-463c-aa0e-9765fc6d7e11","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":87.396,"type":"Other","response":{"url":"http://127.0.0.1:59428/post?token=legacy-fetch-post-03","status":201,"statusText":"Created","headers":{"connection":"keep-alive","content-length":"89","content-type":"application/json; charset=utf-8","date":"Fri, 28 Aug 2026 07:52:02 GMT","keep-alive":"timeout=5","x-manual-case":"legacy-fetch-post-03"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":25,"recordedAt":"2026-08-28T07:52:02.901Z","method":"Network.dataReceived","params":{"requestId":"3e47e0b7-987b-463c-aa0e-9765fc6d7e11","timestamp":87.396,"dataLength":89,"encodedDataLength":89}}
+{"schemaVersion":1,"sequence":26,"recordedAt":"2026-08-28T07:52:02.902Z","method":"Network.loadingFinished","params":{"requestId":"3e47e0b7-987b-463c-aa0e-9765fc6d7e11","timestamp":87.397,"encodedDataLength":89}}
+{"schemaVersion":1,"sequence":27,"recordedAt":"2026-08-28T07:52:06.968Z","method":"Network.requestWillBeSent","params":{"requestId":"1b1612b8-6e03-4483-8d4f-74c232a36835","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/trace?token=legacy-trace-04","request":{"url":"http://127.0.0.1:59428/trace?token=legacy-trace-04","method":"GET","headers":{"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"vendor=manual-pr64","host":"127.0.0.1:59428"},"initialPriority":"High","mixedContentType":"none"},"timestamp":91.466,"wallTime":1787903526.966,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":45,"functionName":"","lineNumber":114,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":0,"functionName":"new Promise","lineNumber":0,"url":""},{"columnNumber":10,"functionName":"httpRequest","lineNumber":112,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":22,"functionName":"runScenario","lineNumber":538,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":28,"recordedAt":"2026-08-28T07:52:06.971Z","method":"Network.responseReceived","params":{"requestId":"1b1612b8-6e03-4483-8d4f-74c232a36835","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":91.468,"type":"Other","response":{"url":"http://127.0.0.1:59428/trace?token=legacy-trace-04","status":200,"statusText":"OK","headers":{"content-type":"application/json; charset=utf-8","content-length":"133","date":"Fri, 28 Aug 2026 07:52:06 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":29,"recordedAt":"2026-08-28T07:52:06.971Z","method":"Network.dataReceived","params":{"requestId":"1b1612b8-6e03-4483-8d4f-74c232a36835","timestamp":91.468,"dataLength":133,"encodedDataLength":133}}
+{"schemaVersion":1,"sequence":30,"recordedAt":"2026-08-28T07:52:06.972Z","method":"Network.loadingFinished","params":{"requestId":"1b1612b8-6e03-4483-8d4f-74c232a36835","timestamp":91.468,"encodedDataLength":133}}
+{"schemaVersion":1,"sequence":31,"recordedAt":"2026-08-28T07:52:10.934Z","method":"Network.requestWillBeSent","params":{"requestId":"25379421-e6bd-43f0-92a2-eb9f2345a4ac","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/reset?token=legacy-failed-05","request":{"url":"http://127.0.0.1:59428/reset?token=legacy-failed-05","method":"GET","headers":{"host":"127.0.0.1:59428"},"initialPriority":"High","mixedContentType":"none"},"timestamp":95.432,"wallTime":1787903530.932,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":45,"functionName":"","lineNumber":114,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":0,"functionName":"new Promise","lineNumber":0,"url":""},{"columnNumber":10,"functionName":"httpRequest","lineNumber":112,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":15,"functionName":"runScenario","lineNumber":546,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":32,"recordedAt":"2026-08-28T07:52:10.936Z","method":"Network.loadingFailed","params":{"requestId":"25379421-e6bd-43f0-92a2-eb9f2345a4ac","timestamp":95.433,"type":"Fetch","errorText":"socket hang up","canceled":false}}
+{"schemaVersion":1,"sequence":33,"recordedAt":"2026-08-28T07:52:14.934Z","method":"Network.requestWillBeSent","params":{"requestId":"21870c2f-ae5c-45b6-a61b-f2827cf4b8fe","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/sse?token=legacy-sse-06","request":{"url":"http://127.0.0.1:59428/sse?token=legacy-sse-06","method":"GET","headers":{},"initialPriority":"High","mixedContentType":"none"},"timestamp":99.432,"wallTime":1787903534.932,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":30,"functionName":"runScenario","lineNumber":559,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":34,"recordedAt":"2026-08-28T07:52:14.938Z","method":"Network.responseReceived","params":{"requestId":"21870c2f-ae5c-45b6-a61b-f2827cf4b8fe","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":99.435,"type":"EventSource","response":{"url":"http://127.0.0.1:59428/sse?token=legacy-sse-06","status":200,"statusText":"OK","headers":{"cache-control":"no-cache","connection":"keep-alive","content-length":"81","content-type":"text/event-stream; charset=utf-8","date":"Fri, 28 Aug 2026 07:52:14 GMT","keep-alive":"timeout=5"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"text/event-stream"}}}
+{"schemaVersion":1,"sequence":35,"recordedAt":"2026-08-28T07:52:14.939Z","method":"Network.eventSourceMessageReceived","params":{"requestId":"21870c2f-ae5c-45b6-a61b-f2827cf4b8fe","timestamp":99.435,"eventName":"manual","eventId":"1","data":"sse-legacy-sse-06"}}
+{"schemaVersion":1,"sequence":36,"recordedAt":"2026-08-28T07:52:14.940Z","method":"Network.eventSourceMessageReceived","params":{"requestId":"21870c2f-ae5c-45b6-a61b-f2827cf4b8fe","timestamp":99.435,"eventName":"message","eventId":"2","data":"complete-legacy-sse-06"}}
+{"schemaVersion":1,"sequence":37,"recordedAt":"2026-08-28T07:52:14.940Z","method":"Network.dataReceived","params":{"requestId":"21870c2f-ae5c-45b6-a61b-f2827cf4b8fe","timestamp":99.435,"dataLength":81,"encodedDataLength":81}}
+{"schemaVersion":1,"sequence":38,"recordedAt":"2026-08-28T07:52:14.941Z","method":"Network.loadingFinished","params":{"requestId":"21870c2f-ae5c-45b6-a61b-f2827cf4b8fe","timestamp":99.435,"encodedDataLength":81}}
+{"schemaVersion":1,"sequence":39,"recordedAt":"2026-08-28T07:52:18.995Z","method":"Network.webSocketCreated","params":{"url":"ws://127.0.0.1:59428/websocket?token=legacy-websocket-07","initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":20,"functionName":"","lineNumber":138,"url":"file:///manual-evidence-server.mjs"},{"columnNumber":0,"functionName":"new Promise","lineNumber":0,"url":""},{"columnNumber":10,"functionName":"webSocketRoundTrip","lineNumber":137,"url":"file:///manual-evidence-server.mjs"},{"columnNumber":92,"functionName":"runScenario","lineNumber":565,"url":"file:///manual-evidence-server.mjs"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8"}}
+{"schemaVersion":1,"sequence":40,"recordedAt":"2026-08-28T07:52:18.998Z","method":"Network.webSocketWillSendHandshakeRequest","params":{"wallTime":1787903538.994,"timestamp":103.493,"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","request":{"headers":{"sec-websocket-version":13,"sec-websocket-key":"zt2WioYgquUhnot+SKWt1w==","connection":"Upgrade","upgrade":"websocket","sec-websocket-extensions":"permessage-deflate; client_max_window_bits","host":"127.0.0.1:59428"}}}}
+{"schemaVersion":1,"sequence":41,"recordedAt":"2026-08-28T07:52:18.999Z","method":"Network.webSocketHandshakeResponseReceived","params":{"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","response":{"headers":{"Upgrade":"websocket","Connection":"Upgrade","Sec-WebSocket-Accept":"V0zn5isdT7rrXXw2VyDyJ+iftJE="},"headersText":"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: V0zn5isdT7rrXXw2VyDyJ+iftJE=","status":101,"statusText":"Switching Protocols","requestHeadersText":"GET ws://127.0.0.1:59428/websocket?token=legacy-websocket-07 HTTP/1.1\r\nsec-websocket-version: 13\r\nsec-websocket-key: zt2WioYgquUhnot+SKWt1w==\r\nconnection: Upgrade\r\nupgrade: websocket\r\nsec-websocket-extensions: permessage-deflate; client_max_window_bits\r\nhost: 127.0.0.1:59428","requestHeaders":{"sec-websocket-version":"13","sec-websocket-key":"zt2WioYgquUhnot+SKWt1w==","connection":"Upgrade","upgrade":"websocket","sec-websocket-extensions":"permessage-deflate; client_max_window_bits","host":"127.0.0.1:59428"}},"timestamp":103.493}}
+{"schemaVersion":1,"sequence":42,"recordedAt":"2026-08-28T07:52:19.000Z","method":"Network.webSocketFrameSent","params":{"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","response":{"payloadData":"client-text:legacy-websocket-07","opcode":1,"mask":true},"timestamp":103.494}}
+{"schemaVersion":1,"sequence":43,"recordedAt":"2026-08-28T07:52:19.001Z","method":"Network.webSocketFrameSent","params":{"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","response":{"payloadData":"AAECf4D+/w==","opcode":2,"mask":true},"timestamp":103.494}}
+{"schemaVersion":1,"sequence":44,"recordedAt":"2026-08-28T07:52:19.002Z","method":"Network.webSocketFrameReceived","params":{"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","response":{"payloadData":"client-text:legacy-websocket-07","opcode":1,"mask":false},"timestamp":103.494}}
+{"schemaVersion":1,"sequence":45,"recordedAt":"2026-08-28T07:52:19.003Z","method":"Network.webSocketFrameReceived","params":{"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","response":{"payloadData":"AAECf4D+/w==","opcode":2,"mask":false},"timestamp":103.495}}
+{"schemaVersion":1,"sequence":46,"recordedAt":"2026-08-28T07:52:19.003Z","method":"Network.webSocketClosed","params":{"requestId":"ef2b1aa4-f871-4c37-a398-91a29ed42bd8","timestamp":103.495}}
+{"schemaVersion":1,"sequence":47,"recordedAt":"2026-08-28T07:52:22.993Z","method":"Network.requestWillBeSent","params":{"requestId":"97ab50a1-7dc0-4698-8eb6-6c585c1b0800","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08","request":{"url":"http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08","method":"GET","headers":{"host":"127.0.0.1:59428"},"initialPriority":"High","mixedContentType":"none"},"timestamp":107.491,"wallTime":1787903542.991,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":45,"functionName":"","lineNumber":114,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":0,"functionName":"new Promise","lineNumber":0,"url":""},{"columnNumber":10,"functionName":"httpRequest","lineNumber":112,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":22,"functionName":"runScenario","lineNumber":516,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":48,"recordedAt":"2026-08-28T07:52:22.994Z","method":"Network.responseReceived","params":{"requestId":"97ab50a1-7dc0-4698-8eb6-6c585c1b0800","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":107.491,"type":"Other","response":{"url":"http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08","status":207,"statusText":"Manual Mock HTTP","headers":{"content-type":"application/json; charset=utf-8","x-nnd-mock":"http","content-length":"67"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":49,"recordedAt":"2026-08-28T07:52:22.995Z","method":"Network.dataReceived","params":{"requestId":"97ab50a1-7dc0-4698-8eb6-6c585c1b0800","timestamp":107.491,"dataLength":67,"encodedDataLength":67}}
+{"schemaVersion":1,"sequence":50,"recordedAt":"2026-08-28T07:52:22.996Z","method":"Network.loadingFinished","params":{"requestId":"97ab50a1-7dc0-4698-8eb6-6c585c1b0800","timestamp":107.491,"encodedDataLength":67}}
+{"schemaVersion":1,"sequence":51,"recordedAt":"2026-08-28T07:52:26.992Z","method":"Network.requestWillBeSent","params":{"requestId":"a85e4f7f-a238-44c7-866e-2b7cfb325da1","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","documentURL":"http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09","request":{"url":"http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09","method":"POST","headers":{"content-type":"text/plain","x-manual-mock":"fetch"},"initialPriority":"High","mixedContentType":"none","postData":"must-not-reach-origin:legacy-mock-fetch-09","hasPostData":true},"timestamp":111.49,"wallTime":1787903546.991,"initiator":{"type":"script","stack":{"callFrames":[{"columnNumber":30,"functionName":"runScenario","lineNumber":520,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":41,"functionName":"","lineNumber":983,"url":"file:///manual-evidence-server.mjs","scriptId":"1"},{"columnNumber":28,"functionName":"Server.emit","lineNumber":509,"url":"node:events"},{"columnNumber":12,"functionName":"parserOnIncoming","lineNumber":1226,"url":"node:_http_server"},{"columnNumber":17,"functionName":"HTTPParser.parserOnHeadersComplete","lineNumber":125,"url":"node:_http_common"}]}},"type":"Fetch"}}
+{"schemaVersion":1,"sequence":52,"recordedAt":"2026-08-28T07:52:26.994Z","method":"Network.responseReceived","params":{"requestId":"a85e4f7f-a238-44c7-866e-2b7cfb325da1","frameId":"nnd.legacy.frame","loaderId":"nnd.legacy.loader","timestamp":111.491,"type":"Other","response":{"url":"http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09","status":202,"statusText":"Manual Mock Fetch","headers":{"content-type":"application/json; charset=utf-8","x-nnd-mock":"fetch"},"connectionReused":false,"encodedDataLength":0,"charset":"utf-8","mimeType":"application/json"}}}
+{"schemaVersion":1,"sequence":53,"recordedAt":"2026-08-28T07:52:26.994Z","method":"Network.dataReceived","params":{"requestId":"a85e4f7f-a238-44c7-866e-2b7cfb325da1","timestamp":111.491,"dataLength":68,"encodedDataLength":68}}
+{"schemaVersion":1,"sequence":54,"recordedAt":"2026-08-28T07:52:26.995Z","method":"Network.loadingFinished","params":{"requestId":"a85e4f7f-a238-44c7-866e-2b7cfb325da1","timestamp":111.491,"encodedDataLength":68}}
diff --git a/output/playwright/pr-64/artifacts/legacy-finalize-summary.json b/output/playwright/pr-64/artifacts/legacy-finalize-summary.json
new file mode 100644
index 0000000..cf339f5
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-finalize-summary.json
@@ -0,0 +1,90 @@
+{
+ "sessionDirectory": "/.runtime/legacy-session-1787903435249",
+ "manualAssertions": {
+ "passed": true,
+ "discovery": {
+ "list": {
+ "ok": true,
+ "targetCount": 1,
+ "targetIdMatches": true
+ },
+ "version": {
+ "ok": true,
+ "browser": "node-network-devtools/2",
+ "protocolVersion": "1.3"
+ },
+ "protocol": {
+ "ok": true,
+ "domainCount": 4,
+ "networkDomain": true,
+ "networkCommandCount": 7,
+ "networkEventCount": 12
+ }
+ },
+ "failedLifecycle": {
+ "requestId": "25379421-e6bd-43f0-92a2-eb9f2345a4ac",
+ "requestWillBeSent": 1,
+ "responseReceived": 0,
+ "loadingFinished": 0,
+ "loadingFailed": 1
+ },
+ "webSocketBoundary": {
+ "lifecycleCreated": 1,
+ "lifecycleClosed": 1,
+ "framesSent": 2,
+ "framesReceived": 2,
+ "expectedFrameCapture": true
+ },
+ "sseBoundary": {
+ "requestCaptured": true,
+ "messageEvents": 2,
+ "expectedMessageCapture": true
+ },
+ "traceBoundary": {
+ "explicitTraceRequests": 1,
+ "explicitTracePreserved": true,
+ "untracedBusinessRequests": 5,
+ "untracedHeadersAbsent": true
+ }
+ },
+ "manifest": {
+ "schemaVersion": 1,
+ "state": "completed",
+ "stats": {
+ "eventCount": 54,
+ "requestCount": 12,
+ "bodyCount": 10,
+ "bodyErrorCount": 0,
+ "failedRequestCount": 1
+ },
+ "issues": [],
+ "traceContexts": [
+ {
+ "requestId": "1b1612b8-6e03-4483-8d4f-74c232a36835",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "tracestate": "vendor=manual-pr64"
+ }
+ ]
+ },
+ "har": {
+ "version": "1.2",
+ "creator": {
+ "name": "node-network-devtools",
+ "version": "2"
+ },
+ "entries": 12,
+ "statuses": [200, 200, 200, 200, 200, 201, 200, 0, 200, 0, 207, 202],
+ "replayableEntries": 6
+ },
+ "replay": {
+ "dryRun": true,
+ "dryRunRequests": 6,
+ "dryRunPassed": true,
+ "realRequests": 6,
+ "realPassed": true
+ },
+ "originLeakCount": 0
+}
diff --git a/output/playwright/pr-64/artifacts/legacy-replayable.har b/output/playwright/pr-64/artifacts/legacy-replayable.har
new file mode 100644
index 0000000..69afa0c
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-replayable.har
@@ -0,0 +1,458 @@
+{
+ "log": {
+ "version": "1.2",
+ "creator": {
+ "name": "node-network-devtools",
+ "version": "2"
+ },
+ "pages": [],
+ "entries": [
+ {
+ "startedDateTime": "2026-08-28T07:51:54.791Z",
+ "time": 2.0000000000095497,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/get?token=legacy-http-get-01",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-http-get-01"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "38"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-http-get-01"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:51:54 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 38,
+ "mimeType": "text/plain",
+ "text": "manual-get-response:legacy-http-get-01"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 38
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 1.0000000000047748,
+ "receive": 1.0000000000047748,
+ "ssl": -1
+ },
+ "_requestId": "58134218-34a0-4b54-a2ab-e1fd65450177"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:02.892Z",
+ "time": 6.000000000000227,
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:59428/post?token=legacy-fetch-post-03",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-fetch-post-03"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-fetch-post-03"
+ }
+ ],
+ "postData": {
+ "mimeType": "text/plain; charset=utf-8",
+ "text": "manual-request-body:legacy-fetch-post-03"
+ },
+ "headersSize": -1,
+ "bodySize": 40
+ },
+ "response": {
+ "status": 201,
+ "statusText": "Created",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-length",
+ "value": "89"
+ },
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:52:02 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-fetch-post-03"
+ }
+ ],
+ "content": {
+ "size": 89,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"legacy-fetch-post-03\",\"requestBody\":\"manual-request-body:legacy-fetch-post-03\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 89
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 4.9999999999954525,
+ "receive": 1.0000000000047748,
+ "ssl": -1
+ },
+ "_requestId": "3e47e0b7-987b-463c-aa0e-9765fc6d7e11"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:06.966Z",
+ "time": 2.0000000000095497,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/trace?token=legacy-trace-04",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "traceparent",
+ "value": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
+ },
+ {
+ "name": "tracestate",
+ "value": "vendor=manual-pr64"
+ },
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-trace-04"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "133"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:52:06 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 133,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"legacy-trace-04\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\",\"tracestate\":\"vendor=manual-pr64\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 133
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 2.0000000000095497,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "1b1612b8-6e03-4483-8d4f-74c232a36835",
+ "_trace": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "version": "00",
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "tracestate": "vendor=manual-pr64"
+ }
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:14.932Z",
+ "time": 3.0000000000001137,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/sse?token=legacy-sse-06",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-sse-06"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "cache-control",
+ "value": "no-cache"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-length",
+ "value": "81"
+ },
+ {
+ "name": "content-type",
+ "value": "text/event-stream; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:52:14 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 81,
+ "mimeType": "text/event-stream",
+ "text": "id: 1\nevent: manual\ndata: sse-legacy-sse-06\n\nid: 2\ndata: complete-legacy-sse-06\n\n"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 81
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 3.0000000000001137,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "21870c2f-ae5c-45b6-a61b-f2827cf4b8fe"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:22.991Z",
+ "time": 0,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-mock-http-08"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 207,
+ "statusText": "Manual Mock HTTP",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "x-nnd-mock",
+ "value": "http"
+ },
+ {
+ "name": "content-length",
+ "value": "67"
+ }
+ ],
+ "content": {
+ "size": 67,
+ "mimeType": "application/json",
+ "text": "{\"mocked\":true,\"transport\":\"http\",\"source\":\"node-network-devtools\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 67
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "97ab50a1-7dc0-4698-8eb6-6c585c1b0800"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:26.991Z",
+ "time": 1.0000000000047748,
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain"
+ },
+ {
+ "name": "x-manual-mock",
+ "value": "fetch"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-mock-fetch-09"
+ }
+ ],
+ "postData": {
+ "mimeType": "text/plain",
+ "text": "must-not-reach-origin:legacy-mock-fetch-09"
+ },
+ "headersSize": -1,
+ "bodySize": 42
+ },
+ "response": {
+ "status": 202,
+ "statusText": "Manual Mock Fetch",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "x-nnd-mock",
+ "value": "fetch"
+ }
+ ],
+ "content": {
+ "size": 68,
+ "mimeType": "application/json",
+ "text": "{\"mocked\":true,\"transport\":\"fetch\",\"source\":\"node-network-devtools\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 68
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 1.0000000000047748,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "a85e4f7f-a238-44c7-866e-2b7cfb325da1"
+ }
+ ]
+ }
+}
diff --git a/output/playwright/pr-64/artifacts/legacy-runtime.json b/output/playwright/pr-64/artifacts/legacy-runtime.json
new file mode 100644
index 0000000..d865da6
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-runtime.json
@@ -0,0 +1,80 @@
+{
+ "schemaVersion": 1,
+ "productCommit": "449d47db89109e826eb0e7e0584777365eac3f9b",
+ "package": {
+ "name": "node-network-devtools",
+ "version": "2.0.0"
+ },
+ "tarballSha256": "e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac",
+ "node": "v24.16.0",
+ "platform": "darwin-arm64",
+ "backend": "legacy",
+ "selectedMode": "legacy",
+ "fallbackReason": {
+ "code": "NND_AUTO_LEGACY_MOCK_REQUIRED",
+ "level": "info",
+ "message": "Auto selected Legacy because request/response mocking was configured.",
+ "hint": "Remove legacy.mock to allow Auto to select the Native backend."
+ },
+ "capabilities": {
+ "http": true,
+ "https": true,
+ "fetch": true,
+ "http2": false,
+ "responseBody": true,
+ "requestBody": true,
+ "websocketLifecycle": true,
+ "websocketFrames": true,
+ "sseMessages": true,
+ "initiator": true
+ },
+ "target": {
+ "id": "node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "title": "Node Network Devtools (Legacy)",
+ "type": "node",
+ "url": "file:///node_modules/node-network-devtools/dist/fork",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "discoveryUrl": "http://127.0.0.1:59430/json/list"
+ },
+ "discovery": [
+ {
+ "id": "node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "title": "Node Network Devtools (Legacy)",
+ "type": "node",
+ "url": "file:///node_modules/node-network-devtools/dist/fork",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "discoveryUrl": "http://127.0.0.1:59430/json/list"
+ }
+ ],
+ "discoveryContract": {
+ "list": {
+ "ok": true,
+ "targetCount": 1,
+ "targetIdMatches": true
+ },
+ "version": {
+ "ok": true,
+ "browser": "node-network-devtools/2",
+ "protocolVersion": "1.3"
+ },
+ "protocol": {
+ "ok": true,
+ "domainCount": 4,
+ "networkDomain": true,
+ "networkCommandCount": 7,
+ "networkEventCount": 12
+ }
+ },
+ "frontendUrl": "http://127.0.0.1:59435/devtools/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68&hl=en-US",
+ "controlUrl": "http://127.0.0.1:59436",
+ "originalFunctionsPreserved": {
+ "fetch": false,
+ "httpRequest": false,
+ "httpsRequest": false
+ },
+ "sourceSha256": "46f81eb2930040a31f402cf4cb113758628eb72c9db27617babba1d3df31af27"
+}
diff --git a/output/playwright/pr-64/artifacts/legacy-session-manifest.json b/output/playwright/pr-64/artifacts/legacy-session-manifest.json
new file mode 100644
index 0000000..16499e8
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-session-manifest.json
@@ -0,0 +1,518 @@
+{
+ "schemaVersion": 1,
+ "sessionId": "fe170256-a52e-497e-8502-5e589fcb7bea",
+ "state": "completed",
+ "createdAt": "2026-08-28T07:50:35.521Z",
+ "target": {
+ "id": "node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "title": "Node Network Devtools (Legacy)",
+ "type": "node",
+ "url": "file:///node_modules/node-network-devtools/dist/fork",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68",
+ "discoveryUrl": "http://127.0.0.1:59430/json/list"
+ },
+ "files": {
+ "events": "events.ndjson",
+ "bodies": "bodies"
+ },
+ "completedAt": "2026-08-28T07:54:18.050Z",
+ "stats": {
+ "eventCount": 54,
+ "requestCount": 12,
+ "bodyCount": 10,
+ "bodyErrorCount": 0,
+ "failedRequestCount": 1
+ },
+ "requestIndex": {
+ "0cc5f65a-40ac-4130-9be9-3f41d81ff929": {
+ "requestId": "0cc5f65a-40ac-4130-9be9-3f41d81ff929",
+ "firstSequence": 2,
+ "requestTimestamp": 0.009,
+ "wallTime": 1787903435.508,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59430/json/list",
+ "method": "GET",
+ "headers": {},
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 0.017,
+ "response": {
+ "url": "http://127.0.0.1:59430/json/list",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "access-control-allow-origin": "*",
+ "cache-control": "no-store",
+ "connection": "keep-alive",
+ "content-type": "application/json; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 07:50:35 GMT",
+ "keep-alive": "timeout=5",
+ "transfer-encoding": "chunked"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 0.018,
+ "encodedDataLength": 821
+ },
+ "9405f647-edce-4fb1-a015-9e7b89878de5": {
+ "requestId": "9405f647-edce-4fb1-a015-9e7b89878de5",
+ "firstSequence": 3,
+ "requestTimestamp": 0.011,
+ "wallTime": 1787903435.512,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59430/json/version",
+ "method": "GET",
+ "headers": {},
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 0.019,
+ "response": {
+ "url": "http://127.0.0.1:59430/json/version",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "access-control-allow-origin": "*",
+ "cache-control": "no-store",
+ "connection": "keep-alive",
+ "content-type": "application/json; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 07:50:35 GMT",
+ "keep-alive": "timeout=5",
+ "transfer-encoding": "chunked"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 0.019,
+ "encodedDataLength": 248
+ },
+ "9e61ccb6-be11-43f9-8355-2d64a4cb6e60": {
+ "requestId": "9e61ccb6-be11-43f9-8355-2d64a4cb6e60",
+ "firstSequence": 4,
+ "requestTimestamp": 0.011,
+ "wallTime": 1787903435.512,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59430/json/protocol",
+ "method": "GET",
+ "headers": {},
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 0.019,
+ "response": {
+ "url": "http://127.0.0.1:59430/json/protocol",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "access-control-allow-origin": "*",
+ "cache-control": "no-store",
+ "connection": "keep-alive",
+ "content-type": "application/json; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 07:50:35 GMT",
+ "keep-alive": "timeout=5",
+ "transfer-encoding": "chunked"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 0.019,
+ "encodedDataLength": 1323
+ },
+ "58134218-34a0-4b54-a2ab-e1fd65450177": {
+ "requestId": "58134218-34a0-4b54-a2ab-e1fd65450177",
+ "firstSequence": 15,
+ "requestTimestamp": 79.291,
+ "wallTime": 1787903514.791,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59428/get?token=legacy-http-get-01",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:59428"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 79.292,
+ "response": {
+ "url": "http://127.0.0.1:59428/get?token=legacy-http-get-01",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "content-length": "38",
+ "x-manual-case": "legacy-http-get-01",
+ "date": "Fri, 28 Aug 2026 07:51:54 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "text/plain"
+ },
+ "finishedTimestamp": 79.293,
+ "encodedDataLength": 38
+ },
+ "679ed0e5-ef59-4d2e-89ab-391343c82c31": {
+ "requestId": "679ed0e5-ef59-4d2e-89ab-391343c82c31",
+ "firstSequence": 19,
+ "requestTimestamp": 83.251,
+ "wallTime": 1787903518.749,
+ "resourceType": "Other",
+ "request": {
+ "url": "https://127.0.0.1:59429/secure-get?token=legacy-https-get-02",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:59429"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 83.257,
+ "response": {
+ "url": "https://127.0.0.1:59429/secure-get?token=legacy-https-get-02",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "content-length": "41",
+ "x-manual-case": "legacy-https-get-02",
+ "date": "Fri, 28 Aug 2026 07:51:58 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "text/plain"
+ },
+ "finishedTimestamp": 83.257,
+ "encodedDataLength": 41
+ },
+ "3e47e0b7-987b-463c-aa0e-9765fc6d7e11": {
+ "requestId": "3e47e0b7-987b-463c-aa0e-9765fc6d7e11",
+ "firstSequence": 23,
+ "requestTimestamp": 87.391,
+ "wallTime": 1787903522.892,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59428/post?token=legacy-fetch-post-03",
+ "method": "POST",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "x-manual-case": "legacy-fetch-post-03"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none",
+ "postData": "manual-request-body:legacy-fetch-post-03",
+ "hasPostData": true
+ },
+ "responseTimestamp": 87.396,
+ "response": {
+ "url": "http://127.0.0.1:59428/post?token=legacy-fetch-post-03",
+ "status": 201,
+ "statusText": "Created",
+ "headers": {
+ "connection": "keep-alive",
+ "content-length": "89",
+ "content-type": "application/json; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 07:52:02 GMT",
+ "keep-alive": "timeout=5",
+ "x-manual-case": "legacy-fetch-post-03"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 87.397,
+ "encodedDataLength": 89
+ },
+ "1b1612b8-6e03-4483-8d4f-74c232a36835": {
+ "requestId": "1b1612b8-6e03-4483-8d4f-74c232a36835",
+ "firstSequence": 27,
+ "requestTimestamp": 91.466,
+ "wallTime": 1787903526.966,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59428/trace?token=legacy-trace-04",
+ "method": "GET",
+ "headers": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "tracestate": "vendor=manual-pr64",
+ "host": "127.0.0.1:59428"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "trace": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "version": "00",
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "tracestate": "vendor=manual-pr64"
+ },
+ "responseTimestamp": 91.468,
+ "response": {
+ "url": "http://127.0.0.1:59428/trace?token=legacy-trace-04",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": "133",
+ "date": "Fri, 28 Aug 2026 07:52:06 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 91.468,
+ "encodedDataLength": 133
+ },
+ "25379421-e6bd-43f0-92a2-eb9f2345a4ac": {
+ "requestId": "25379421-e6bd-43f0-92a2-eb9f2345a4ac",
+ "firstSequence": 31,
+ "requestTimestamp": 95.432,
+ "wallTime": 1787903530.932,
+ "resourceType": "Fetch",
+ "request": {
+ "url": "http://127.0.0.1:59428/reset?token=legacy-failed-05",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:59428"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "finishedTimestamp": 95.433,
+ "failure": {
+ "errorText": "socket hang up",
+ "canceled": false
+ }
+ },
+ "21870c2f-ae5c-45b6-a61b-f2827cf4b8fe": {
+ "requestId": "21870c2f-ae5c-45b6-a61b-f2827cf4b8fe",
+ "firstSequence": 33,
+ "requestTimestamp": 99.432,
+ "wallTime": 1787903534.932,
+ "resourceType": "EventSource",
+ "request": {
+ "url": "http://127.0.0.1:59428/sse?token=legacy-sse-06",
+ "method": "GET",
+ "headers": {},
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 99.435,
+ "response": {
+ "url": "http://127.0.0.1:59428/sse?token=legacy-sse-06",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "cache-control": "no-cache",
+ "connection": "keep-alive",
+ "content-length": "81",
+ "content-type": "text/event-stream; charset=utf-8",
+ "date": "Fri, 28 Aug 2026 07:52:14 GMT",
+ "keep-alive": "timeout=5"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "text/event-stream"
+ },
+ "finishedTimestamp": 99.435,
+ "encodedDataLength": 81
+ },
+ "ef2b1aa4-f871-4c37-a398-91a29ed42bd8": {
+ "requestId": "ef2b1aa4-f871-4c37-a398-91a29ed42bd8",
+ "firstSequence": 39
+ },
+ "97ab50a1-7dc0-4698-8eb6-6c585c1b0800": {
+ "requestId": "97ab50a1-7dc0-4698-8eb6-6c585c1b0800",
+ "firstSequence": 47,
+ "requestTimestamp": 107.491,
+ "wallTime": 1787903542.991,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:59428"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none"
+ },
+ "responseTimestamp": 107.491,
+ "response": {
+ "url": "http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08",
+ "status": 207,
+ "statusText": "Manual Mock HTTP",
+ "headers": {
+ "content-type": "application/json; charset=utf-8",
+ "x-nnd-mock": "http",
+ "content-length": "67"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 107.491,
+ "encodedDataLength": 67
+ },
+ "a85e4f7f-a238-44c7-866e-2b7cfb325da1": {
+ "requestId": "a85e4f7f-a238-44c7-866e-2b7cfb325da1",
+ "firstSequence": 51,
+ "requestTimestamp": 111.49,
+ "wallTime": 1787903546.991,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09",
+ "method": "POST",
+ "headers": {
+ "content-type": "text/plain",
+ "x-manual-mock": "fetch"
+ },
+ "initialPriority": "High",
+ "mixedContentType": "none",
+ "postData": "must-not-reach-origin:legacy-mock-fetch-09",
+ "hasPostData": true
+ },
+ "responseTimestamp": 111.491,
+ "response": {
+ "url": "http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09",
+ "status": 202,
+ "statusText": "Manual Mock Fetch",
+ "headers": {
+ "content-type": "application/json; charset=utf-8",
+ "x-nnd-mock": "fetch"
+ },
+ "connectionReused": false,
+ "encodedDataLength": 0,
+ "charset": "utf-8",
+ "mimeType": "application/json"
+ },
+ "finishedTimestamp": 111.491,
+ "encodedDataLength": 68
+ }
+ },
+ "bodyIndex": {
+ "0cc5f65a-40ac-4130-9be9-3f41d81ff929": {
+ "requestId": "0cc5f65a-40ac-4130-9be9-3f41d81ff929",
+ "path": "bodies/9d4da820dd90637b-4d42de0bb5b2269011ec887a81c45783270bc1fa034d2b16913b29a466eed86d.body",
+ "sha256": "4d42de0bb5b2269011ec887a81c45783270bc1fa034d2b16913b29a466eed86d",
+ "byteLength": 821,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "9405f647-edce-4fb1-a015-9e7b89878de5": {
+ "requestId": "9405f647-edce-4fb1-a015-9e7b89878de5",
+ "path": "bodies/856a19bcfdf19a59-bbf42bcaa1d02652fd51fb30e5a96e01da3b85f015fd1635b2adcee547d08444.body",
+ "sha256": "bbf42bcaa1d02652fd51fb30e5a96e01da3b85f015fd1635b2adcee547d08444",
+ "byteLength": 248,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "9e61ccb6-be11-43f9-8355-2d64a4cb6e60": {
+ "requestId": "9e61ccb6-be11-43f9-8355-2d64a4cb6e60",
+ "path": "bodies/1e5129332eeffbac-59b00c761bb938bbd60117eeafca6eb93ed4c9df481b492b37cd14fbcb1d121a.body",
+ "sha256": "59b00c761bb938bbd60117eeafca6eb93ed4c9df481b492b37cd14fbcb1d121a",
+ "byteLength": 1323,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "58134218-34a0-4b54-a2ab-e1fd65450177": {
+ "requestId": "58134218-34a0-4b54-a2ab-e1fd65450177",
+ "path": "bodies/2d08ba5699d46627-4dbbb1b839dde1985bb99f2e0191e702c723ed9c0270c8863fa7a15b1a4d1241.body",
+ "sha256": "4dbbb1b839dde1985bb99f2e0191e702c723ed9c0270c8863fa7a15b1a4d1241",
+ "byteLength": 38,
+ "base64Encoded": false,
+ "mimeType": "text/plain"
+ },
+ "679ed0e5-ef59-4d2e-89ab-391343c82c31": {
+ "requestId": "679ed0e5-ef59-4d2e-89ab-391343c82c31",
+ "path": "bodies/dc8bd877cc1f50c9-3da027641befa9a07e036caa733aff0e6bbdbe2d79bd51592a2b98af03823010.body",
+ "sha256": "3da027641befa9a07e036caa733aff0e6bbdbe2d79bd51592a2b98af03823010",
+ "byteLength": 41,
+ "base64Encoded": false,
+ "mimeType": "text/plain"
+ },
+ "3e47e0b7-987b-463c-aa0e-9765fc6d7e11": {
+ "requestId": "3e47e0b7-987b-463c-aa0e-9765fc6d7e11",
+ "path": "bodies/d520b6573b0f1805-06a511ade7e9e2ec749546b3c19cfa3ad033741a2431101b2b8d5c4c8f773686.body",
+ "sha256": "06a511ade7e9e2ec749546b3c19cfa3ad033741a2431101b2b8d5c4c8f773686",
+ "byteLength": 89,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "1b1612b8-6e03-4483-8d4f-74c232a36835": {
+ "requestId": "1b1612b8-6e03-4483-8d4f-74c232a36835",
+ "path": "bodies/d0b35c18e5a6e6fa-7ea97b7efb1716877e99643b9309fab128cfc9b4361865629e7355ff2a619adf.body",
+ "sha256": "7ea97b7efb1716877e99643b9309fab128cfc9b4361865629e7355ff2a619adf",
+ "byteLength": 133,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "21870c2f-ae5c-45b6-a61b-f2827cf4b8fe": {
+ "requestId": "21870c2f-ae5c-45b6-a61b-f2827cf4b8fe",
+ "path": "bodies/285b774da6e19996-099ad30fee6432ff6c49a303917619cf899ffa89ce05a26d87a5e118bea4cae0.body",
+ "sha256": "099ad30fee6432ff6c49a303917619cf899ffa89ce05a26d87a5e118bea4cae0",
+ "byteLength": 81,
+ "base64Encoded": false,
+ "mimeType": "text/event-stream"
+ },
+ "97ab50a1-7dc0-4698-8eb6-6c585c1b0800": {
+ "requestId": "97ab50a1-7dc0-4698-8eb6-6c585c1b0800",
+ "path": "bodies/8ad3dde286c71e26-bf55b18bcb5c10539e5529e2f80c8bea6021a6ec17d157d21be2c3ec53c46a11.body",
+ "sha256": "bf55b18bcb5c10539e5529e2f80c8bea6021a6ec17d157d21be2c3ec53c46a11",
+ "byteLength": 67,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "a85e4f7f-a238-44c7-866e-2b7cfb325da1": {
+ "requestId": "a85e4f7f-a238-44c7-866e-2b7cfb325da1",
+ "path": "bodies/5e5ac978a5a357db-2529fff188d8b259ca882f4c4ab063d29f11b43e18b2ed57718fea224673ddda.body",
+ "sha256": "2529fff188d8b259ca882f4c4ab063d29f11b43e18b2ed57718fea224673ddda",
+ "byteLength": 68,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ }
+ },
+ "traceIndex": {
+ "4bf92f3577b34da6a3ce929d0e0e4736": {
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "requestIds": ["1b1612b8-6e03-4483-8d4f-74c232a36835"],
+ "spans": [
+ {
+ "requestId": "1b1612b8-6e03-4483-8d4f-74c232a36835",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "tracestate": "vendor=manual-pr64"
+ }
+ ]
+ }
+ },
+ "issues": []
+}
diff --git a/output/playwright/pr-64/artifacts/legacy-session.har b/output/playwright/pr-64/artifacts/legacy-session.har
new file mode 100644
index 0000000..8deee68
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/legacy-session.har
@@ -0,0 +1,832 @@
+{
+ "log": {
+ "version": "1.2",
+ "creator": {
+ "name": "node-network-devtools",
+ "version": "2"
+ },
+ "pages": [],
+ "entries": [
+ {
+ "startedDateTime": "2026-08-28T07:50:35.508Z",
+ "time": 9,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59430/json/list",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "access-control-allow-origin",
+ "value": "*"
+ },
+ {
+ "name": "cache-control",
+ "value": "no-store"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:50:35 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ },
+ {
+ "name": "transfer-encoding",
+ "value": "chunked"
+ }
+ ],
+ "content": {
+ "size": 821,
+ "mimeType": "application/json",
+ "text": "[{\"id\":\"node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68\",\"title\":\"Node Network Devtools (Legacy)\",\"type\":\"node\",\"url\":\"file:///node_modules/node-network-devtools/dist/fork\",\"webSocketDebuggerUrl\":\"ws://127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68\",\"devtoolsFrontendUrl\":\"devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68\",\"devtoolsFrontendUrlCompat\":\"devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68\",\"discoveryUrl\":\"http://127.0.0.1:59430/json/list\"}]"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 821
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 8.000000000000002,
+ "receive": 0.9999999999999974,
+ "ssl": -1
+ },
+ "_requestId": "0cc5f65a-40ac-4130-9be9-3f41d81ff929"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:50:35.512Z",
+ "time": 8,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59430/json/version",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "access-control-allow-origin",
+ "value": "*"
+ },
+ {
+ "name": "cache-control",
+ "value": "no-store"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:50:35 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ },
+ {
+ "name": "transfer-encoding",
+ "value": "chunked"
+ }
+ ],
+ "content": {
+ "size": 248,
+ "mimeType": "application/json",
+ "text": "{\"Browser\":\"node-network-devtools/2\",\"Protocol-Version\":\"1.3\",\"User-Agent\":\"Node.js/v24.16.0\",\"V8-Version\":\"13.6.233.17-node.49\",\"webSocketDebuggerUrl\":\"ws://127.0.0.1:59430/devtools/page/node-network-devtools-b4efbb3a-70c3-435c-a554-567e33524f68\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 248
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 8,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "9405f647-edce-4fb1-a015-9e7b89878de5"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:50:35.512Z",
+ "time": 8,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59430/json/protocol",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "access-control-allow-origin",
+ "value": "*"
+ },
+ {
+ "name": "cache-control",
+ "value": "no-store"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:50:35 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ },
+ {
+ "name": "transfer-encoding",
+ "value": "chunked"
+ }
+ ],
+ "content": {
+ "size": 1323,
+ "mimeType": "application/json",
+ "text": "{\"version\":{\"major\":\"1\",\"minor\":\"3\"},\"domains\":[{\"domain\":\"Network\",\"version\":\"1.3\",\"commands\":[{\"name\":\"enable\"},{\"name\":\"disable\"},{\"name\":\"setAttachDebugStack\"},{\"name\":\"emulateNetworkConditionsByRule\",\"returns\":[{\"name\":\"ruleIds\",\"type\":\"array\",\"items\":{\"type\":\"string\"}}]},{\"name\":\"overrideNetworkState\"},{\"name\":\"clearAcceptedEncodingsOverride\"},{\"name\":\"getResponseBody\",\"parameters\":[{\"name\":\"requestId\",\"type\":\"string\"}],\"returns\":[{\"name\":\"body\",\"type\":\"string\"},{\"name\":\"base64Encoded\",\"type\":\"boolean\"}]}],\"events\":[{\"name\":\"requestWillBeSent\"},{\"name\":\"responseReceived\"},{\"name\":\"dataReceived\"},{\"name\":\"loadingFinished\"},{\"name\":\"loadingFailed\"},{\"name\":\"eventSourceMessageReceived\"},{\"name\":\"webSocketCreated\"},{\"name\":\"webSocketWillSendHandshakeRequest\"},{\"name\":\"webSocketHandshakeResponseReceived\"},{\"name\":\"webSocketFrameSent\"},{\"name\":\"webSocketFrameReceived\"},{\"name\":\"webSocketClosed\"}]},{\"domain\":\"Debugger\",\"version\":\"1.3\",\"commands\":[{\"name\":\"enable\"},{\"name\":\"disable\"},{\"name\":\"getScriptSource\",\"parameters\":[{\"name\":\"scriptId\",\"type\":\"string\"}],\"returns\":[{\"name\":\"scriptSource\",\"type\":\"string\"}]}],\"events\":[{\"name\":\"scriptParsed\"}]},{\"domain\":\"Runtime\",\"version\":\"1.3\",\"commands\":[{\"name\":\"enable\"},{\"name\":\"disable\"}]},{\"domain\":\"Schema\",\"version\":\"1.3\",\"commands\":[{\"name\":\"getDomains\"}]}]}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 1323
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 8,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "9e61ccb6-be11-43f9-8355-2d64a4cb6e60"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:51:54.791Z",
+ "time": 2.0000000000095497,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/get?token=legacy-http-get-01",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-http-get-01"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "38"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-http-get-01"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:51:54 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 38,
+ "mimeType": "text/plain",
+ "text": "manual-get-response:legacy-http-get-01"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 38
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 1.0000000000047748,
+ "receive": 1.0000000000047748,
+ "ssl": -1
+ },
+ "_requestId": "58134218-34a0-4b54-a2ab-e1fd65450177"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:51:58.749Z",
+ "time": 6.000000000000227,
+ "request": {
+ "method": "GET",
+ "url": "https://127.0.0.1:59429/secure-get?token=legacy-https-get-02",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:59429"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-https-get-02"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "41"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-https-get-02"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:51:58 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 41,
+ "mimeType": "text/plain",
+ "text": "manual-https-response:legacy-https-get-02"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 41
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 6.000000000000227,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "679ed0e5-ef59-4d2e-89ab-391343c82c31"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:02.892Z",
+ "time": 6.000000000000227,
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:59428/post?token=legacy-fetch-post-03",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-fetch-post-03"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-fetch-post-03"
+ }
+ ],
+ "postData": {
+ "mimeType": "text/plain; charset=utf-8",
+ "text": "manual-request-body:legacy-fetch-post-03"
+ },
+ "headersSize": -1,
+ "bodySize": 40
+ },
+ "response": {
+ "status": 201,
+ "statusText": "Created",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-length",
+ "value": "89"
+ },
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:52:02 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "legacy-fetch-post-03"
+ }
+ ],
+ "content": {
+ "size": 89,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"legacy-fetch-post-03\",\"requestBody\":\"manual-request-body:legacy-fetch-post-03\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 89
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 4.9999999999954525,
+ "receive": 1.0000000000047748,
+ "ssl": -1
+ },
+ "_requestId": "3e47e0b7-987b-463c-aa0e-9765fc6d7e11"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:06.966Z",
+ "time": 2.0000000000095497,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/trace?token=legacy-trace-04",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "traceparent",
+ "value": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
+ },
+ {
+ "name": "tracestate",
+ "value": "vendor=manual-pr64"
+ },
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-trace-04"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "133"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:52:06 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 133,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"legacy-trace-04\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\",\"tracestate\":\"vendor=manual-pr64\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 133
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 2.0000000000095497,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "1b1612b8-6e03-4483-8d4f-74c232a36835",
+ "_trace": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "version": "00",
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "tracestate": "vendor=manual-pr64"
+ }
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:10.932Z",
+ "time": 1.0000000000047748,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/reset?token=legacy-failed-05",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-failed-05"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 0,
+ "statusText": "socket hang up",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "content": {
+ "size": 0,
+ "mimeType": "application/octet-stream"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "25379421-e6bd-43f0-92a2-eb9f2345a4ac",
+ "_failure": {
+ "errorText": "socket hang up",
+ "canceled": false
+ }
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:14.932Z",
+ "time": 3.0000000000001137,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/sse?token=legacy-sse-06",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-sse-06"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "cache-control",
+ "value": "no-cache"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "content-length",
+ "value": "81"
+ },
+ {
+ "name": "content-type",
+ "value": "text/event-stream; charset=utf-8"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:52:14 GMT"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 81,
+ "mimeType": "text/event-stream",
+ "text": "id: 1\nevent: manual\ndata: sse-legacy-sse-06\n\nid: 2\ndata: complete-legacy-sse-06\n\n"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 81
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 3.0000000000001137,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "21870c2f-ae5c-45b6-a61b-f2827cf4b8fe"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:50:35.521Z",
+ "time": 0,
+ "request": {
+ "method": "GET",
+ "url": "",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 0,
+ "statusText": "",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "content": {
+ "size": 0,
+ "mimeType": "application/octet-stream"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "ef2b1aa4-f871-4c37-a398-91a29ed42bd8"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:22.991Z",
+ "time": 0,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:59428/mock-http?token=legacy-mock-http-08",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:59428"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-mock-http-08"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 207,
+ "statusText": "Manual Mock HTTP",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "x-nnd-mock",
+ "value": "http"
+ },
+ {
+ "name": "content-length",
+ "value": "67"
+ }
+ ],
+ "content": {
+ "size": 67,
+ "mimeType": "application/json",
+ "text": "{\"mocked\":true,\"transport\":\"http\",\"source\":\"node-network-devtools\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 67
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "97ab50a1-7dc0-4698-8eb6-6c585c1b0800"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:52:26.991Z",
+ "time": 1.0000000000047748,
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:59428/mock-fetch?token=legacy-mock-fetch-09",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain"
+ },
+ {
+ "name": "x-manual-mock",
+ "value": "fetch"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "legacy-mock-fetch-09"
+ }
+ ],
+ "postData": {
+ "mimeType": "text/plain",
+ "text": "must-not-reach-origin:legacy-mock-fetch-09"
+ },
+ "headersSize": -1,
+ "bodySize": 42
+ },
+ "response": {
+ "status": 202,
+ "statusText": "Manual Mock Fetch",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "x-nnd-mock",
+ "value": "fetch"
+ }
+ ],
+ "content": {
+ "size": 68,
+ "mimeType": "application/json",
+ "text": "{\"mocked\":true,\"transport\":\"fetch\",\"source\":\"node-network-devtools\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 68
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 1.0000000000047748,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "a85e4f7f-a238-44c7-866e-2b7cfb325da1"
+ }
+ ]
+ }
+}
diff --git a/output/playwright/pr-64/artifacts/native-dispose-summary.json b/output/playwright/pr-64/artifacts/native-dispose-summary.json
new file mode 100644
index 0000000..7f17c58
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-dispose-summary.json
@@ -0,0 +1,5 @@
+{
+ "registrationState": "disposed",
+ "discoveryClosed": true,
+ "targetSocketClosed": true
+}
diff --git a/output/playwright/pr-64/artifacts/native-events.ndjson b/output/playwright/pr-64/artifacts/native-events.ndjson
new file mode 100644
index 0000000..c0258bc
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-events.ndjson
@@ -0,0 +1,24 @@
+{"schemaVersion":1,"sequence":1,"recordedAt":"2026-08-28T07:47:55.120Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-1","request":{"url":"http://127.0.0.1:58925/get?token=native-http-get-01","method":"GET","headers":{"host":"127.0.0.1:58925"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestCreated","scriptId":"369","url":"node:internal/inspector/network_http","lineNumber":81,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"ClientRequest","scriptId":"149","url":"node:_http_client","lineNumber":442,"columnNumber":34},{"functionName":"request","scriptId":"146","url":"node:http","lineNumber":107,"columnNumber":9},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":113,"columnNumber":44},{"functionName":"httpRequest","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":111,"columnNumber":9},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":493,"columnNumber":21},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":86.3044,"wallTime":1787903275119}}
+{"schemaVersion":1,"sequence":2,"recordedAt":"2026-08-28T07:47:55.124Z","method":"Network.responseReceived","params":{"requestId":"node-network-event-1","timestamp":86.3068,"type":"Other","response":{"url":"http://127.0.0.1:58925/get?token=native-http-get-01","status":200,"statusText":"OK","headers":{"content-type":"text/plain; charset=utf-8","content-length":"38","x-manual-case":"native-http-get-01","date":"Fri, 28 Aug 2026 07:47:55 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"mimeType":"text/plain","charset":"utf-8"}}}
+{"schemaVersion":1,"sequence":3,"recordedAt":"2026-08-28T07:47:55.126Z","method":"Network.loadingFinished","params":{"requestId":"node-network-event-1","timestamp":86.3071}}
+{"schemaVersion":1,"sequence":4,"recordedAt":"2026-08-28T07:47:59.053Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-2","request":{"url":"https://127.0.0.1:58926/secure-get?token=native-https-get-02","method":"GET","headers":{"host":"127.0.0.1:58926"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestCreated","scriptId":"369","url":"node:internal/inspector/network_http","lineNumber":81,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"ClientRequest","scriptId":"149","url":"node:_http_client","lineNumber":442,"columnNumber":34},{"functionName":"request","scriptId":"194","url":"node:https","lineNumber":628,"columnNumber":9},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":113,"columnNumber":44},{"functionName":"httpRequest","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":111,"columnNumber":9},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":496,"columnNumber":21},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":90.2372,"wallTime":1787903279052}}
+{"schemaVersion":1,"sequence":5,"recordedAt":"2026-08-28T07:47:59.063Z","method":"Network.responseReceived","params":{"requestId":"node-network-event-2","timestamp":90.245,"type":"Other","response":{"url":"https://127.0.0.1:58926/secure-get?token=native-https-get-02","status":200,"statusText":"OK","headers":{"content-type":"text/plain; charset=utf-8","content-length":"41","x-manual-case":"native-https-get-02","date":"Fri, 28 Aug 2026 07:47:59 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"mimeType":"text/plain","charset":"utf-8"}}}
+{"schemaVersion":1,"sequence":6,"recordedAt":"2026-08-28T07:47:59.065Z","method":"Network.loadingFinished","params":{"requestId":"node-network-event-2","timestamp":90.2455}}
+{"schemaVersion":1,"sequence":7,"recordedAt":"2026-08-28T07:48:03.102Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-3","request":{"url":"http://127.0.0.1:58925/post?token=native-fetch-post-03","method":"POST","headers":{"content-type":"text/plain; charset=utf-8","x-manual-case":"native-fetch-post-03","accept":"*/*","accept-language":"*","sec-fetch-mode":"cors","user-agent":"node","accept-encoding":"gzip, deflate"},"hasPostData":true},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestStart","scriptId":"372","url":"node:internal/inspector/network_undici","lineNumber":77,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"Request","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2991,"columnNumber":26},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":9231,"columnNumber":24},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2563,"columnNumber":31},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":9705,"columnNumber":26},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13638,"columnNumber":54},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13638,"columnNumber":15},{"functionName":"httpNetworkFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13536,"columnNumber":72},{"functionName":"httpNetworkOrCacheFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13406,"columnNumber":38},{"functionName":"httpFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13228,"columnNumber":42},{"functionName":"schemeFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13145,"columnNumber":17},{"functionName":"mainFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12989,"columnNumber":29},{"functionName":"fetching","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12958,"columnNumber":6},{"functionName":"fetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12822,"columnNumber":19},{"functionName":"fetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":17455,"columnNumber":9},{"functionName":"fetch","scriptId":"64","url":"node:internal/bootstrap/web/exposed-window-or-worker","lineNumber":82,"columnNumber":11},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":501,"columnNumber":29},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":94.2859,"wallTime":1787903283101}}
+{"schemaVersion":1,"sequence":8,"recordedAt":"2026-08-28T07:48:03.114Z","method":"Network.responseReceived","params":{"requestId":"node-network-event-3","timestamp":94.2956,"type":"Fetch","response":{"url":"http://127.0.0.1:58925/post?token=native-fetch-post-03","status":201,"statusText":"Created","headers":{"content-type":"application/json; charset=utf-8","content-length":"89","x-manual-case":"native-fetch-post-03","Date":"Fri, 28 Aug 2026 07:48:03 GMT","Connection":"keep-alive","Keep-Alive":"timeout=5"},"mimeType":"application/json","charset":"utf-8"}}}
+{"schemaVersion":1,"sequence":9,"recordedAt":"2026-08-28T07:48:03.115Z","method":"Network.loadingFinished","params":{"requestId":"node-network-event-3","timestamp":94.296}}
+{"schemaVersion":1,"sequence":10,"recordedAt":"2026-08-28T07:48:07.067Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-4","request":{"url":"http://127.0.0.1:58925/trace?token=native-trace-04","method":"GET","headers":{"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01","tracestate":"vendor=manual-pr64","host":"127.0.0.1:58925"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestCreated","scriptId":"369","url":"node:internal/inspector/network_http","lineNumber":81,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"ClientRequest","scriptId":"149","url":"node:_http_client","lineNumber":442,"columnNumber":34},{"functionName":"request","scriptId":"146","url":"node:http","lineNumber":107,"columnNumber":9},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":113,"columnNumber":44},{"functionName":"httpRequest","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":111,"columnNumber":9},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":537,"columnNumber":21},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":98.2512,"wallTime":1787903287066}}
+{"schemaVersion":1,"sequence":11,"recordedAt":"2026-08-28T07:48:07.071Z","method":"Network.responseReceived","params":{"requestId":"node-network-event-4","timestamp":98.2539,"type":"Other","response":{"url":"http://127.0.0.1:58925/trace?token=native-trace-04","status":200,"statusText":"OK","headers":{"content-type":"application/json; charset=utf-8","content-length":"133","date":"Fri, 28 Aug 2026 07:48:07 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"mimeType":"application/json","charset":"utf-8"}}}
+{"schemaVersion":1,"sequence":12,"recordedAt":"2026-08-28T07:48:07.072Z","method":"Network.loadingFinished","params":{"requestId":"node-network-event-4","timestamp":98.2542}}
+{"schemaVersion":1,"sequence":13,"recordedAt":"2026-08-28T07:48:11.025Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-5","request":{"url":"http://127.0.0.1:58925/reset?token=native-failed-05","method":"GET","headers":{"host":"127.0.0.1:58925"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestCreated","scriptId":"369","url":"node:internal/inspector/network_http","lineNumber":81,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"ClientRequest","scriptId":"149","url":"node:_http_client","lineNumber":442,"columnNumber":34},{"functionName":"request","scriptId":"146","url":"node:http","lineNumber":107,"columnNumber":9},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":113,"columnNumber":44},{"functionName":"httpRequest","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":111,"columnNumber":9},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":545,"columnNumber":14},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":102.21,"wallTime":1787903291025}}
+{"schemaVersion":1,"sequence":14,"recordedAt":"2026-08-28T07:48:11.028Z","method":"Network.loadingFailed","params":{"requestId":"node-network-event-5","timestamp":102.212,"type":"Other","errorText":"socket hang up"}}
+{"schemaVersion":1,"sequence":15,"recordedAt":"2026-08-28T07:48:15.010Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-6","request":{"url":"http://127.0.0.1:58925/sse?token=native-sse-06","method":"GET","headers":{"accept":"*/*","accept-language":"*","sec-fetch-mode":"cors","user-agent":"node","accept-encoding":"gzip, deflate"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestStart","scriptId":"372","url":"node:internal/inspector/network_undici","lineNumber":77,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"Request","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2991,"columnNumber":26},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":9231,"columnNumber":24},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2563,"columnNumber":31},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":9705,"columnNumber":26},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13638,"columnNumber":54},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13638,"columnNumber":15},{"functionName":"httpNetworkFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13536,"columnNumber":72},{"functionName":"httpNetworkOrCacheFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13406,"columnNumber":38},{"functionName":"httpFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13228,"columnNumber":42},{"functionName":"schemeFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13145,"columnNumber":17},{"functionName":"mainFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12989,"columnNumber":29},{"functionName":"fetching","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12958,"columnNumber":6},{"functionName":"fetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12822,"columnNumber":19},{"functionName":"fetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":17455,"columnNumber":9},{"functionName":"fetch","scriptId":"64","url":"node:internal/bootstrap/web/exposed-window-or-worker","lineNumber":82,"columnNumber":11},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":558,"columnNumber":29},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":106.193,"wallTime":1787903295008}}
+{"schemaVersion":1,"sequence":16,"recordedAt":"2026-08-28T07:48:15.015Z","method":"Network.responseReceived","params":{"requestId":"node-network-event-6","timestamp":106.198,"type":"Fetch","response":{"url":"http://127.0.0.1:58925/sse?token=native-sse-06","status":200,"statusText":"OK","headers":{"content-type":"text/event-stream; charset=utf-8","content-length":"81","cache-control":"no-cache","Date":"Fri, 28 Aug 2026 07:48:15 GMT","Connection":"keep-alive","Keep-Alive":"timeout=5"},"mimeType":"text/event-stream","charset":"utf-8"}}}
+{"schemaVersion":1,"sequence":17,"recordedAt":"2026-08-28T07:48:15.017Z","method":"Network.loadingFinished","params":{"requestId":"node-network-event-6","timestamp":106.198}}
+{"schemaVersion":1,"sequence":18,"recordedAt":"2026-08-28T07:48:18.968Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-7","request":{"url":"http://127.0.0.1:58925/websocket?token=native-websocket-07","method":"GET","headers":{"sec-websocket-key":"iVtSkmOz5zD/H90P5l+npg==","sec-websocket-version":"13","sec-websocket-extensions":"permessage-deflate; client_max_window_bits","accept":"*/*","accept-language":"*","sec-fetch-mode":"websocket","user-agent":"node","pragma":"no-cache","cache-control":"no-cache","accept-encoding":"gzip, deflate"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestStart","scriptId":"372","url":"node:internal/inspector/network_undici","lineNumber":77,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"Request","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2991,"columnNumber":26},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":9231,"columnNumber":24},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2563,"columnNumber":31},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"[dispatch]","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":9705,"columnNumber":26},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":2292,"columnNumber":32},{"functionName":"","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13638,"columnNumber":54},{"functionName":"dispatch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13638,"columnNumber":15},{"functionName":"httpNetworkFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13536,"columnNumber":72},{"functionName":"httpNetworkOrCacheFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13406,"columnNumber":38},{"functionName":"httpFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13228,"columnNumber":42},{"functionName":"schemeFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13145,"columnNumber":17},{"functionName":"mainFetch","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12989,"columnNumber":29},{"functionName":"fetching","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":12958,"columnNumber":6},{"functionName":"establishWebSocketConnection","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":14503,"columnNumber":25},{"functionName":"WebSocket","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":15253,"columnNumber":35},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":159,"columnNumber":19},{"functionName":"nativeWebSocketRoundTrip","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":158,"columnNumber":9},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":564,"columnNumber":91},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":110.152,"wallTime":1787903298967}}
+{"schemaVersion":1,"sequence":19,"recordedAt":"2026-08-28T07:48:18.977Z","method":"Network.webSocketCreated","params":{"requestId":"node-network-event-8","url":"ws://127.0.0.1:58925/websocket?token=native-websocket-07","initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"webSocketCreated","scriptId":"302","url":"node:inspector","lineNumber":221,"columnNumber":32},{"functionName":"onWebSocketOpen","scriptId":"372","url":"node:internal/inspector/network_undici","lineNumber":214,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"#onConnectionEstablished","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":15441,"columnNumber":24},{"functionName":"onConnectionEstablished","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":15185,"columnNumber":111},{"functionName":"processResponse","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":14561,"columnNumber":18},{"functionName":"","scriptId":"155","url":"node:internal/deps/undici/undici","lineNumber":13203,"columnNumber":22},{"functionName":"","scriptId":"32","url":"node:internal/process/task_queues","lineNumber":150,"columnNumber":6},{"functionName":"runInAsyncScope","scriptId":"35","url":"node:async_hooks","lineNumber":226,"columnNumber":13},{"functionName":"runMicrotask","scriptId":"32","url":"node:internal/process/task_queues","lineNumber":147,"columnNumber":7},{"functionName":"processTicksAndRejections","scriptId":"32","url":"node:internal/process/task_queues","lineNumber":103,"columnNumber":4}]}}}}
+{"schemaVersion":1,"sequence":20,"recordedAt":"2026-08-28T07:48:18.979Z","method":"Network.webSocketHandshakeResponseReceived","params":{"requestId":"node-network-event-8","timestamp":110.157,"response":{"status":101,"statusText":"Switching Protocols","headers":{"upgrade":"websocket","connection":"Upgrade","sec-websocket-accept":"QwNMRFhpyLgDRWoVQNFiDz4Z3Wg="}}}}
+{"schemaVersion":1,"sequence":21,"recordedAt":"2026-08-28T07:48:18.980Z","method":"Network.webSocketClosed","params":{"requestId":"node-network-event-8","timestamp":110.163}}
+{"schemaVersion":1,"sequence":22,"recordedAt":"2026-08-28T07:49:35.675Z","method":"Network.requestWillBeSent","params":{"requestId":"node-network-event-9","request":{"url":"http://127.0.0.1:58925/get?token=native-http-get-08","method":"GET","headers":{"host":"127.0.0.1:58925"},"hasPostData":false},"initiator":{"type":"script","stack":{"callFrames":[{"functionName":"broadcastToFrontend","scriptId":"302","url":"node:inspector","lineNumber":211,"columnNumber":2},{"functionName":"requestWillBeSent","scriptId":"302","url":"node:inspector","lineNumber":215,"columnNumber":33},{"functionName":"onClientRequestCreated","scriptId":"369","url":"node:internal/inspector/network_http","lineNumber":81,"columnNumber":10},{"functionName":"publish","scriptId":"31","url":"node:diagnostics_channel","lineNumber":164,"columnNumber":8},{"functionName":"ClientRequest","scriptId":"149","url":"node:_http_client","lineNumber":442,"columnNumber":34},{"functionName":"request","scriptId":"146","url":"node:http","lineNumber":107,"columnNumber":9},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":113,"columnNumber":44},{"functionName":"httpRequest","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":111,"columnNumber":9},{"functionName":"runScenario","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":493,"columnNumber":21},{"functionName":"","scriptId":"87","url":"file:///manual-evidence-server.mjs","lineNumber":982,"columnNumber":40},{"functionName":"emit","scriptId":"22","url":"node:events","lineNumber":508,"columnNumber":27},{"functionName":"parserOnIncoming","scriptId":"154","url":"node:_http_server","lineNumber":1225,"columnNumber":11},{"functionName":"parserOnHeadersComplete","scriptId":"150","url":"node:_http_common","lineNumber":124,"columnNumber":16}]}},"timestamp":186.86,"wallTime":1787903375674}}
+{"schemaVersion":1,"sequence":23,"recordedAt":"2026-08-28T07:49:35.679Z","method":"Network.responseReceived","params":{"requestId":"node-network-event-9","timestamp":186.862,"type":"Other","response":{"url":"http://127.0.0.1:58925/get?token=native-http-get-08","status":200,"statusText":"OK","headers":{"content-type":"text/plain; charset=utf-8","content-length":"38","x-manual-case":"native-http-get-08","date":"Fri, 28 Aug 2026 07:49:35 GMT","connection":"keep-alive","keep-alive":"timeout=5"},"mimeType":"text/plain","charset":"utf-8"}}}
+{"schemaVersion":1,"sequence":24,"recordedAt":"2026-08-28T07:49:35.680Z","method":"Network.loadingFinished","params":{"requestId":"node-network-event-9","timestamp":186.863}}
diff --git a/output/playwright/pr-64/artifacts/native-finalize-summary.json b/output/playwright/pr-64/artifacts/native-finalize-summary.json
new file mode 100644
index 0000000..9a594b6
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-finalize-summary.json
@@ -0,0 +1,90 @@
+{
+ "sessionDirectory": "/.runtime/native-session-1787903188869",
+ "manualAssertions": {
+ "passed": true,
+ "discovery": {
+ "list": {
+ "ok": true,
+ "targetCount": 1,
+ "targetIdMatches": true
+ },
+ "version": {
+ "ok": true,
+ "browser": "node.js/v24.16.0",
+ "protocolVersion": "1.1"
+ },
+ "protocol": {
+ "ok": true,
+ "domainCount": 14,
+ "networkDomain": true,
+ "networkCommandCount": 6,
+ "networkEventCount": 8
+ }
+ },
+ "failedLifecycle": {
+ "requestId": "node-network-event-5",
+ "requestWillBeSent": 1,
+ "responseReceived": 0,
+ "loadingFinished": 0,
+ "loadingFailed": 1
+ },
+ "webSocketBoundary": {
+ "lifecycleCreated": 1,
+ "lifecycleClosed": 1,
+ "framesSent": 0,
+ "framesReceived": 0,
+ "expectedFrameCapture": false
+ },
+ "sseBoundary": {
+ "requestCaptured": true,
+ "messageEvents": 0,
+ "expectedMessageCapture": false
+ },
+ "traceBoundary": {
+ "explicitTraceRequests": 1,
+ "explicitTracePreserved": true,
+ "untracedBusinessRequests": 6,
+ "untracedHeadersAbsent": true
+ }
+ },
+ "manifest": {
+ "schemaVersion": 1,
+ "state": "completed",
+ "stats": {
+ "eventCount": 24,
+ "requestCount": 9,
+ "bodyCount": 6,
+ "bodyErrorCount": 0,
+ "failedRequestCount": 1
+ },
+ "issues": [],
+ "traceContexts": [
+ {
+ "requestId": "node-network-event-4",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "tracestate": "vendor=manual-pr64"
+ }
+ ]
+ },
+ "har": {
+ "version": "1.2",
+ "creator": {
+ "name": "node-network-devtools",
+ "version": "2"
+ },
+ "entries": 9,
+ "statuses": [200, 200, 201, 200, 0, 200, 0, 0, 200],
+ "replayableEntries": 5
+ },
+ "replay": {
+ "dryRun": true,
+ "dryRunRequests": 5,
+ "dryRunPassed": true,
+ "realRequests": 5,
+ "realPassed": true
+ },
+ "originLeakCount": 0
+}
diff --git a/output/playwright/pr-64/artifacts/native-http2-probe.json b/output/playwright/pr-64/artifacts/native-http2-probe.json
new file mode 100644
index 0000000..7aed7cd
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-http2-probe.json
@@ -0,0 +1,50 @@
+{
+ "schemaVersion": 1,
+ "testedProductCommit": "449d47db89109e826eb0e7e0584777365eac3f9b",
+ "tarballSha256": "e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac",
+ "probe": "manual-native-http2-probe.mjs",
+ "request": {
+ "protocol": "h2c",
+ "status": 200,
+ "responseBody": "h2-ok",
+ "consumerBehavior": "request.setEncoding('utf8') plus data consumption"
+ },
+ "cases": [
+ {
+ "node": "v22.22.3",
+ "exitCode": 0,
+ "advertisedHttp2": true,
+ "eventSequence": [
+ "Network.requestWillBeSent",
+ "Network.responseReceived",
+ "Network.loadingFinished"
+ ],
+ "receivedBody": "h2-ok",
+ "outcome": "complete lifecycle"
+ },
+ {
+ "node": "v24.16.0",
+ "exitCode": 1,
+ "advertisedHttp2": false,
+ "eventSequence": ["Network.requestWillBeSent", "Network.responseReceived"],
+ "receivedBody": null,
+ "error": "TypeError: Missing dataLength in event",
+ "source": "node:internal/inspector/network_http2:222",
+ "outcome": "upstream Inspector crash reproduced; capability correctly withheld"
+ },
+ {
+ "node": "v26.8.1",
+ "exitCode": 1,
+ "advertisedHttp2": false,
+ "eventSequence": ["Network.requestWillBeSent", "Network.responseReceived"],
+ "receivedBody": null,
+ "error": "TypeError: Missing dataLength in event",
+ "source": "node:internal/inspector/network_http2:223",
+ "outcome": "upstream Inspector crash reproduced; capability correctly withheld"
+ }
+ ],
+ "assertion": {
+ "status": "PASS",
+ "reason": "The exact package advertises HTTP/2 only on the verified Node 22.20+ 22.x line and does not inherit support into affected or future majors."
+ }
+}
diff --git a/output/playwright/pr-64/artifacts/native-replayable.har b/output/playwright/pr-64/artifacts/native-replayable.har
new file mode 100644
index 0000000..82e19b3
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-replayable.har
@@ -0,0 +1,440 @@
+{
+ "log": {
+ "version": "1.2",
+ "creator": {
+ "name": "node-network-devtools",
+ "version": "2"
+ },
+ "pages": [],
+ "entries": [
+ {
+ "startedDateTime": "2026-08-28T07:47:55.119Z",
+ "time": 2.7000000000043656,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-01",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-http-get-01"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "38"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-http-get-01"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:47:55 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 38,
+ "mimeType": "text/plain",
+ "text": "manual-get-response:native-http-get-01"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 38
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 2.3999999999944066,
+ "receive": 0.30000000000995897,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-1"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:03.101Z",
+ "time": 10.100000000008436,
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:58925/post?token=native-fetch-post-03",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-fetch-post-03"
+ },
+ {
+ "name": "accept",
+ "value": "*/*"
+ },
+ {
+ "name": "accept-language",
+ "value": "*"
+ },
+ {
+ "name": "sec-fetch-mode",
+ "value": "cors"
+ },
+ {
+ "name": "user-agent",
+ "value": "node"
+ },
+ {
+ "name": "accept-encoding",
+ "value": "gzip, deflate"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-fetch-post-03"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 201,
+ "statusText": "Created",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "89"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-fetch-post-03"
+ },
+ {
+ "name": "Date",
+ "value": "Fri, 28 Aug 2026 07:48:03 GMT"
+ },
+ {
+ "name": "Connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "Keep-Alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 89,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"native-fetch-post-03\",\"requestBody\":\"manual-request-body:native-fetch-post-03\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 89
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 9.699999999995157,
+ "receive": 0.4000000000132786,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-3"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:07.066Z",
+ "time": 3.0000000000001137,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/trace?token=native-trace-04",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "traceparent",
+ "value": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
+ },
+ {
+ "name": "tracestate",
+ "value": "vendor=manual-pr64"
+ },
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-trace-04"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "133"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:48:07 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 133,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"native-trace-04\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\",\"tracestate\":\"vendor=manual-pr64\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 133
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 2.7000000000043656,
+ "receive": 0.2999999999957481,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-4",
+ "_trace": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "version": "00",
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "tracestate": "vendor=manual-pr64"
+ }
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:15.008Z",
+ "time": 4.9999999999954525,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/sse?token=native-sse-06",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "accept",
+ "value": "*/*"
+ },
+ {
+ "name": "accept-language",
+ "value": "*"
+ },
+ {
+ "name": "sec-fetch-mode",
+ "value": "cors"
+ },
+ {
+ "name": "user-agent",
+ "value": "node"
+ },
+ {
+ "name": "accept-encoding",
+ "value": "gzip, deflate"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-sse-06"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/event-stream; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "81"
+ },
+ {
+ "name": "cache-control",
+ "value": "no-cache"
+ },
+ {
+ "name": "Date",
+ "value": "Fri, 28 Aug 2026 07:48:15 GMT"
+ },
+ {
+ "name": "Connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "Keep-Alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 81,
+ "mimeType": "text/event-stream",
+ "text": "id: 1\nevent: manual\ndata: sse-native-sse-06\n\nid: 2\ndata: complete-native-sse-06\n\n"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 81
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 4.9999999999954525,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-6"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:49:35.674Z",
+ "time": 2.999999999985903,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-08",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-http-get-08"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "38"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-http-get-08"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:49:35 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 38,
+ "mimeType": "text/plain",
+ "text": "manual-get-response:native-http-get-08"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 38
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 1.999999999981128,
+ "receive": 1.0000000000047748,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-9"
+ }
+ ]
+ }
+}
diff --git a/output/playwright/pr-64/artifacts/native-runtime.json b/output/playwright/pr-64/artifacts/native-runtime.json
new file mode 100644
index 0000000..075aa33
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-runtime.json
@@ -0,0 +1,76 @@
+{
+ "schemaVersion": 1,
+ "productCommit": "449d47db89109e826eb0e7e0584777365eac3f9b",
+ "package": {
+ "name": "node-network-devtools",
+ "version": "2.0.0"
+ },
+ "tarballSha256": "e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac",
+ "node": "v24.16.0",
+ "platform": "darwin-arm64",
+ "backend": "native",
+ "selectedMode": "native",
+ "fallbackReason": null,
+ "capabilities": {
+ "http": true,
+ "https": true,
+ "fetch": true,
+ "http2": false,
+ "responseBody": true,
+ "requestBody": false,
+ "websocketLifecycle": true,
+ "websocketFrames": false,
+ "sseMessages": false,
+ "initiator": true
+ },
+ "target": {
+ "id": "c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "title": "output/playwright/pr-64/manual-evidence-server.mjs",
+ "type": "node",
+ "url": "file:///manual-evidence-server.mjs",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "discoveryUrl": "http://127.0.0.1:58927/json/list"
+ },
+ "discovery": [
+ {
+ "description": "node.js instance",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "faviconUrl": "https://nodejs.org/static/images/favicons/favicon.ico",
+ "id": "c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "title": "output/playwright/pr-64/manual-evidence-server.mjs",
+ "type": "node",
+ "url": "file:///manual-evidence-server.mjs",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5"
+ }
+ ],
+ "discoveryContract": {
+ "list": {
+ "ok": true,
+ "targetCount": 1,
+ "targetIdMatches": true
+ },
+ "version": {
+ "ok": true,
+ "browser": "node.js/v24.16.0",
+ "protocolVersion": "1.1"
+ },
+ "protocol": {
+ "ok": true,
+ "domainCount": 14,
+ "networkDomain": true,
+ "networkCommandCount": 6,
+ "networkEventCount": 8
+ }
+ },
+ "frontendUrl": "http://127.0.0.1:58933/devtools/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5&hl=en-US",
+ "controlUrl": "http://127.0.0.1:58934",
+ "originalFunctionsPreserved": {
+ "fetch": true,
+ "httpRequest": true,
+ "httpsRequest": true
+ },
+ "sourceSha256": "46f81eb2930040a31f402cf4cb113758628eb72c9db27617babba1d3df31af27"
+}
diff --git a/output/playwright/pr-64/artifacts/native-session-manifest.json b/output/playwright/pr-64/artifacts/native-session-manifest.json
new file mode 100644
index 0000000..1661ea9
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-session-manifest.json
@@ -0,0 +1,354 @@
+{
+ "schemaVersion": 1,
+ "sessionId": "96007c32-4e84-481a-8012-953ccaac8a57",
+ "state": "completed",
+ "createdAt": "2026-08-28T07:46:29.081Z",
+ "target": {
+ "id": "c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "title": "output/playwright/pr-64/manual-evidence-server.mjs",
+ "type": "node",
+ "url": "file:///manual-evidence-server.mjs",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:58927/c91298d7-d6f3-4ef5-8a54-f6ef4a0393b5",
+ "discoveryUrl": "http://127.0.0.1:58927/json/list"
+ },
+ "files": {
+ "events": "events.ndjson",
+ "bodies": "bodies"
+ },
+ "completedAt": "2026-08-28T07:49:48.484Z",
+ "stats": {
+ "eventCount": 24,
+ "requestCount": 9,
+ "bodyCount": 6,
+ "bodyErrorCount": 0,
+ "failedRequestCount": 1
+ },
+ "requestIndex": {
+ "node-network-event-1": {
+ "requestId": "node-network-event-1",
+ "firstSequence": 1,
+ "requestTimestamp": 86.3044,
+ "wallTime": 1787903275119,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-01",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:58925"
+ },
+ "hasPostData": false
+ },
+ "responseTimestamp": 86.3068,
+ "response": {
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-01",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "content-length": "38",
+ "x-manual-case": "native-http-get-01",
+ "date": "Fri, 28 Aug 2026 07:47:55 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "mimeType": "text/plain",
+ "charset": "utf-8"
+ },
+ "finishedTimestamp": 86.3071
+ },
+ "node-network-event-2": {
+ "requestId": "node-network-event-2",
+ "firstSequence": 4,
+ "requestTimestamp": 90.2372,
+ "wallTime": 1787903279052,
+ "resourceType": "Other",
+ "request": {
+ "url": "https://127.0.0.1:58926/secure-get?token=native-https-get-02",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:58926"
+ },
+ "hasPostData": false
+ },
+ "responseTimestamp": 90.245,
+ "response": {
+ "url": "https://127.0.0.1:58926/secure-get?token=native-https-get-02",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "content-length": "41",
+ "x-manual-case": "native-https-get-02",
+ "date": "Fri, 28 Aug 2026 07:47:59 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "mimeType": "text/plain",
+ "charset": "utf-8"
+ },
+ "finishedTimestamp": 90.2455
+ },
+ "node-network-event-3": {
+ "requestId": "node-network-event-3",
+ "firstSequence": 7,
+ "requestTimestamp": 94.2859,
+ "wallTime": 1787903283101,
+ "resourceType": "Fetch",
+ "request": {
+ "url": "http://127.0.0.1:58925/post?token=native-fetch-post-03",
+ "method": "POST",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "x-manual-case": "native-fetch-post-03",
+ "accept": "*/*",
+ "accept-language": "*",
+ "sec-fetch-mode": "cors",
+ "user-agent": "node",
+ "accept-encoding": "gzip, deflate"
+ },
+ "hasPostData": true
+ },
+ "responseTimestamp": 94.2956,
+ "response": {
+ "url": "http://127.0.0.1:58925/post?token=native-fetch-post-03",
+ "status": 201,
+ "statusText": "Created",
+ "headers": {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": "89",
+ "x-manual-case": "native-fetch-post-03",
+ "Date": "Fri, 28 Aug 2026 07:48:03 GMT",
+ "Connection": "keep-alive",
+ "Keep-Alive": "timeout=5"
+ },
+ "mimeType": "application/json",
+ "charset": "utf-8"
+ },
+ "finishedTimestamp": 94.296
+ },
+ "node-network-event-4": {
+ "requestId": "node-network-event-4",
+ "firstSequence": 10,
+ "requestTimestamp": 98.2512,
+ "wallTime": 1787903287066,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:58925/trace?token=native-trace-04",
+ "method": "GET",
+ "headers": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "tracestate": "vendor=manual-pr64",
+ "host": "127.0.0.1:58925"
+ },
+ "hasPostData": false
+ },
+ "trace": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "version": "00",
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "tracestate": "vendor=manual-pr64"
+ },
+ "responseTimestamp": 98.2539,
+ "response": {
+ "url": "http://127.0.0.1:58925/trace?token=native-trace-04",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": "133",
+ "date": "Fri, 28 Aug 2026 07:48:07 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "mimeType": "application/json",
+ "charset": "utf-8"
+ },
+ "finishedTimestamp": 98.2542
+ },
+ "node-network-event-5": {
+ "requestId": "node-network-event-5",
+ "firstSequence": 13,
+ "requestTimestamp": 102.21,
+ "wallTime": 1787903291025,
+ "request": {
+ "url": "http://127.0.0.1:58925/reset?token=native-failed-05",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:58925"
+ },
+ "hasPostData": false
+ },
+ "finishedTimestamp": 102.212,
+ "failure": {
+ "errorText": "socket hang up"
+ }
+ },
+ "node-network-event-6": {
+ "requestId": "node-network-event-6",
+ "firstSequence": 15,
+ "requestTimestamp": 106.193,
+ "wallTime": 1787903295008,
+ "resourceType": "Fetch",
+ "request": {
+ "url": "http://127.0.0.1:58925/sse?token=native-sse-06",
+ "method": "GET",
+ "headers": {
+ "accept": "*/*",
+ "accept-language": "*",
+ "sec-fetch-mode": "cors",
+ "user-agent": "node",
+ "accept-encoding": "gzip, deflate"
+ },
+ "hasPostData": false
+ },
+ "responseTimestamp": 106.198,
+ "response": {
+ "url": "http://127.0.0.1:58925/sse?token=native-sse-06",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "text/event-stream; charset=utf-8",
+ "content-length": "81",
+ "cache-control": "no-cache",
+ "Date": "Fri, 28 Aug 2026 07:48:15 GMT",
+ "Connection": "keep-alive",
+ "Keep-Alive": "timeout=5"
+ },
+ "mimeType": "text/event-stream",
+ "charset": "utf-8"
+ },
+ "finishedTimestamp": 106.198
+ },
+ "node-network-event-7": {
+ "requestId": "node-network-event-7",
+ "firstSequence": 18,
+ "requestTimestamp": 110.152,
+ "wallTime": 1787903298967,
+ "request": {
+ "url": "http://127.0.0.1:58925/websocket?token=native-websocket-07",
+ "method": "GET",
+ "headers": {
+ "sec-websocket-key": "iVtSkmOz5zD/H90P5l+npg==",
+ "sec-websocket-version": "13",
+ "sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
+ "accept": "*/*",
+ "accept-language": "*",
+ "sec-fetch-mode": "websocket",
+ "user-agent": "node",
+ "pragma": "no-cache",
+ "cache-control": "no-cache",
+ "accept-encoding": "gzip, deflate"
+ },
+ "hasPostData": false
+ }
+ },
+ "node-network-event-8": {
+ "requestId": "node-network-event-8",
+ "firstSequence": 19
+ },
+ "node-network-event-9": {
+ "requestId": "node-network-event-9",
+ "firstSequence": 22,
+ "requestTimestamp": 186.86,
+ "wallTime": 1787903375674,
+ "resourceType": "Other",
+ "request": {
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-08",
+ "method": "GET",
+ "headers": {
+ "host": "127.0.0.1:58925"
+ },
+ "hasPostData": false
+ },
+ "responseTimestamp": 186.862,
+ "response": {
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-08",
+ "status": 200,
+ "statusText": "OK",
+ "headers": {
+ "content-type": "text/plain; charset=utf-8",
+ "content-length": "38",
+ "x-manual-case": "native-http-get-08",
+ "date": "Fri, 28 Aug 2026 07:49:35 GMT",
+ "connection": "keep-alive",
+ "keep-alive": "timeout=5"
+ },
+ "mimeType": "text/plain",
+ "charset": "utf-8"
+ },
+ "finishedTimestamp": 186.863
+ }
+ },
+ "bodyIndex": {
+ "node-network-event-1": {
+ "requestId": "node-network-event-1",
+ "path": "bodies/31cc118298853420-8c7e40e0ebf9259cbe9ca6193c5963e56e6cfe3cb357c8771e779d74ee374208.body",
+ "sha256": "8c7e40e0ebf9259cbe9ca6193c5963e56e6cfe3cb357c8771e779d74ee374208",
+ "byteLength": 38,
+ "base64Encoded": false,
+ "mimeType": "text/plain"
+ },
+ "node-network-event-2": {
+ "requestId": "node-network-event-2",
+ "path": "bodies/e83ceaae23523325-1f1c69670cc5d899a65e5b968b2275f0d41e77d46ea3b50a62a3fa611201d6b4.body",
+ "sha256": "1f1c69670cc5d899a65e5b968b2275f0d41e77d46ea3b50a62a3fa611201d6b4",
+ "byteLength": 41,
+ "base64Encoded": false,
+ "mimeType": "text/plain"
+ },
+ "node-network-event-3": {
+ "requestId": "node-network-event-3",
+ "path": "bodies/6ed405c68abbc3e2-f607b5ba8e0e9dd7ac298b7e45424e59d2cdcb21d620d9dc3aabac6ec089a198.body",
+ "sha256": "f607b5ba8e0e9dd7ac298b7e45424e59d2cdcb21d620d9dc3aabac6ec089a198",
+ "byteLength": 89,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "node-network-event-4": {
+ "requestId": "node-network-event-4",
+ "path": "bodies/98fa223a6be7aac1-28b7171e7edc8622367ffe1a9d56d463e5c79846041266e3d122b03a7121622a.body",
+ "sha256": "28b7171e7edc8622367ffe1a9d56d463e5c79846041266e3d122b03a7121622a",
+ "byteLength": 133,
+ "base64Encoded": false,
+ "mimeType": "application/json"
+ },
+ "node-network-event-6": {
+ "requestId": "node-network-event-6",
+ "path": "bodies/560ae859abcea7a8-b85d7e9db80dcd113d5b1b870dbfe8480f4b087a6c51343f64fb107fda7eecfb.body",
+ "sha256": "b85d7e9db80dcd113d5b1b870dbfe8480f4b087a6c51343f64fb107fda7eecfb",
+ "byteLength": 81,
+ "base64Encoded": false,
+ "mimeType": "text/event-stream"
+ },
+ "node-network-event-9": {
+ "requestId": "node-network-event-9",
+ "path": "bodies/c3a84d1b8ac9c35a-cf096cabf3ea54d3263c5638aee98e7a20fb9394de4b8732d0b4170c0859ba5e.body",
+ "sha256": "cf096cabf3ea54d3263c5638aee98e7a20fb9394de4b8732d0b4170c0859ba5e",
+ "byteLength": 38,
+ "base64Encoded": false,
+ "mimeType": "text/plain"
+ }
+ },
+ "traceIndex": {
+ "4bf92f3577b34da6a3ce929d0e0e4736": {
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "requestIds": ["node-network-event-4"],
+ "spans": [
+ {
+ "requestId": "node-network-event-4",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "tracestate": "vendor=manual-pr64"
+ }
+ ]
+ }
+ },
+ "issues": []
+}
diff --git a/output/playwright/pr-64/artifacts/native-session.har b/output/playwright/pr-64/artifacts/native-session.har
new file mode 100644
index 0000000..ce30c22
--- /dev/null
+++ b/output/playwright/pr-64/artifacts/native-session.har
@@ -0,0 +1,691 @@
+{
+ "log": {
+ "version": "1.2",
+ "creator": {
+ "name": "node-network-devtools",
+ "version": "2"
+ },
+ "pages": [],
+ "entries": [
+ {
+ "startedDateTime": "2026-08-28T07:47:55.119Z",
+ "time": 2.7000000000043656,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-01",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-http-get-01"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "38"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-http-get-01"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:47:55 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 38,
+ "mimeType": "text/plain",
+ "text": "manual-get-response:native-http-get-01"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 38
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 2.3999999999944066,
+ "receive": 0.30000000000995897,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-1"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:47:59.052Z",
+ "time": 8.300000000005525,
+ "request": {
+ "method": "GET",
+ "url": "https://127.0.0.1:58926/secure-get?token=native-https-get-02",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:58926"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-https-get-02"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "41"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-https-get-02"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:47:59 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 41,
+ "mimeType": "text/plain",
+ "text": "manual-https-response:native-https-get-02"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 41
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 7.800000000003138,
+ "receive": 0.5000000000023874,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-2"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:03.101Z",
+ "time": 10.100000000008436,
+ "request": {
+ "method": "POST",
+ "url": "http://127.0.0.1:58925/post?token=native-fetch-post-03",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-fetch-post-03"
+ },
+ {
+ "name": "accept",
+ "value": "*/*"
+ },
+ {
+ "name": "accept-language",
+ "value": "*"
+ },
+ {
+ "name": "sec-fetch-mode",
+ "value": "cors"
+ },
+ {
+ "name": "user-agent",
+ "value": "node"
+ },
+ {
+ "name": "accept-encoding",
+ "value": "gzip, deflate"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-fetch-post-03"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 201,
+ "statusText": "Created",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "89"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-fetch-post-03"
+ },
+ {
+ "name": "Date",
+ "value": "Fri, 28 Aug 2026 07:48:03 GMT"
+ },
+ {
+ "name": "Connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "Keep-Alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 89,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"native-fetch-post-03\",\"requestBody\":\"manual-request-body:native-fetch-post-03\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 89
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 9.699999999995157,
+ "receive": 0.4000000000132786,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-3"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:07.066Z",
+ "time": 3.0000000000001137,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/trace?token=native-trace-04",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "traceparent",
+ "value": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
+ },
+ {
+ "name": "tracestate",
+ "value": "vendor=manual-pr64"
+ },
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-trace-04"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "application/json; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "133"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:48:07 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 133,
+ "mimeType": "application/json",
+ "text": "{\"token\":\"native-trace-04\",\"traceparent\":\"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\",\"tracestate\":\"vendor=manual-pr64\"}"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 133
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 2.7000000000043656,
+ "receive": 0.2999999999957481,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-4",
+ "_trace": {
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
+ "version": "00",
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "parentId": "00f067aa0ba902b7",
+ "traceFlags": "01",
+ "sampled": true,
+ "tracestate": "vendor=manual-pr64"
+ }
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:11.025Z",
+ "time": 2.0000000000095497,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/reset?token=native-failed-05",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-failed-05"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 0,
+ "statusText": "socket hang up",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "content": {
+ "size": 0,
+ "mimeType": "application/octet-stream"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-5",
+ "_failure": {
+ "errorText": "socket hang up"
+ }
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:15.008Z",
+ "time": 4.9999999999954525,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/sse?token=native-sse-06",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "accept",
+ "value": "*/*"
+ },
+ {
+ "name": "accept-language",
+ "value": "*"
+ },
+ {
+ "name": "sec-fetch-mode",
+ "value": "cors"
+ },
+ {
+ "name": "user-agent",
+ "value": "node"
+ },
+ {
+ "name": "accept-encoding",
+ "value": "gzip, deflate"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-sse-06"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/event-stream; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "81"
+ },
+ {
+ "name": "cache-control",
+ "value": "no-cache"
+ },
+ {
+ "name": "Date",
+ "value": "Fri, 28 Aug 2026 07:48:15 GMT"
+ },
+ {
+ "name": "Connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "Keep-Alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 81,
+ "mimeType": "text/event-stream",
+ "text": "id: 1\nevent: manual\ndata: sse-native-sse-06\n\nid: 2\ndata: complete-native-sse-06\n\n"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 81
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 4.9999999999954525,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-6"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:48:18.967Z",
+ "time": 0,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/websocket?token=native-websocket-07",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "sec-websocket-key",
+ "value": "iVtSkmOz5zD/H90P5l+npg=="
+ },
+ {
+ "name": "sec-websocket-version",
+ "value": "13"
+ },
+ {
+ "name": "sec-websocket-extensions",
+ "value": "permessage-deflate; client_max_window_bits"
+ },
+ {
+ "name": "accept",
+ "value": "*/*"
+ },
+ {
+ "name": "accept-language",
+ "value": "*"
+ },
+ {
+ "name": "sec-fetch-mode",
+ "value": "websocket"
+ },
+ {
+ "name": "user-agent",
+ "value": "node"
+ },
+ {
+ "name": "pragma",
+ "value": "no-cache"
+ },
+ {
+ "name": "cache-control",
+ "value": "no-cache"
+ },
+ {
+ "name": "accept-encoding",
+ "value": "gzip, deflate"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-websocket-07"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 0,
+ "statusText": "",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "content": {
+ "size": 0,
+ "mimeType": "application/octet-stream"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-7"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:46:29.081Z",
+ "time": 0,
+ "request": {
+ "method": "GET",
+ "url": "",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "queryString": [],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 0,
+ "statusText": "",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [],
+ "content": {
+ "size": 0,
+ "mimeType": "application/octet-stream"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 0,
+ "receive": 0,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-8"
+ },
+ {
+ "startedDateTime": "2026-08-28T07:49:35.674Z",
+ "time": 2.999999999985903,
+ "request": {
+ "method": "GET",
+ "url": "http://127.0.0.1:58925/get?token=native-http-get-08",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "host",
+ "value": "127.0.0.1:58925"
+ }
+ ],
+ "queryString": [
+ {
+ "name": "token",
+ "value": "native-http-get-08"
+ }
+ ],
+ "headersSize": -1,
+ "bodySize": 0
+ },
+ "response": {
+ "status": 200,
+ "statusText": "OK",
+ "httpVersion": "HTTP/1.1",
+ "cookies": [],
+ "headers": [
+ {
+ "name": "content-type",
+ "value": "text/plain; charset=utf-8"
+ },
+ {
+ "name": "content-length",
+ "value": "38"
+ },
+ {
+ "name": "x-manual-case",
+ "value": "native-http-get-08"
+ },
+ {
+ "name": "date",
+ "value": "Fri, 28 Aug 2026 07:49:35 GMT"
+ },
+ {
+ "name": "connection",
+ "value": "keep-alive"
+ },
+ {
+ "name": "keep-alive",
+ "value": "timeout=5"
+ }
+ ],
+ "content": {
+ "size": 38,
+ "mimeType": "text/plain",
+ "text": "manual-get-response:native-http-get-08"
+ },
+ "redirectURL": "",
+ "headersSize": -1,
+ "bodySize": 38
+ },
+ "cache": {},
+ "timings": {
+ "blocked": -1,
+ "dns": -1,
+ "connect": -1,
+ "send": -1,
+ "wait": 1.999999999981128,
+ "receive": 1.0000000000047748,
+ "ssl": -1
+ },
+ "_requestId": "node-network-event-9"
+ }
+ ]
+ }
+}
diff --git a/output/playwright/pr-64/checksums.sha256 b/output/playwright/pr-64/checksums.sha256
new file mode 100644
index 0000000..c4dba30
--- /dev/null
+++ b/output/playwright/pr-64/checksums.sha256
@@ -0,0 +1,51 @@
+f34efafe391d151dde34d036eab4e101d41a79fef639d89667b4a2c7d7edc34b artifacts/cli-manual-results.json
+d4eca8f827e76e30fd41456143c8f8e821b858b6258b9ae5b7f3f823eff884ca artifacts/cli-replay-fixture.har
+ef734c395e25dec7edf8c58f08bdcf200e6504890d2639242c7faff0c5d4f361 artifacts/legacy-dispose-summary.json
+1c8dc82c4389c8398ffcc858a9887b4eec3c92252d96214bb0fd1e1b95c41f6e artifacts/legacy-events.ndjson
+c4199e87cde4792f0d5659a8dba6f6bc87d9d38fd24e5e17fe5b4c07e9b5d2be artifacts/legacy-finalize-summary.json
+d0cfb108f63d85fdb71a038a0b5d55b3752cbb194492063df118312ca8267961 artifacts/legacy-replayable.har
+4ee556d247e811da5377a2b313284b7b787509ed79aa7fa8959362aec1de44e2 artifacts/legacy-runtime.json
+6fd54e0ef211120b3a38ea623b349809194ae4b76937e2ff2cd10f0d851c87dd artifacts/legacy-session-manifest.json
+71ba517f3b9922b36f53e9eac7dda3d96cc3fc787dfd80142f9f34df58872587 artifacts/legacy-session.har
+ef734c395e25dec7edf8c58f08bdcf200e6504890d2639242c7faff0c5d4f361 artifacts/native-dispose-summary.json
+7f19518e3f71418f7d94ad8085ac6c538a9b721b3e31dc285709390ed8069df3 artifacts/native-events.ndjson
+865d93beae666def9e9fa07eea2dc89ee557e68cdd03410cc71acb857c71612a artifacts/native-finalize-summary.json
+8089726ff924c3dc62c66b4cd05bb206a4328b4e36c4cc81288740b2ebac2390 artifacts/native-http2-probe.json
+a9db6dc74b322c39307d8ca0276672377873ddcf0d9c39b3ec3d2349cf6f4b84 artifacts/native-replayable.har
+50023a8d9644d373a32750e09b7c601a1f1601aa1aba40528d79ff5b561f7d50 artifacts/native-runtime.json
+38612d7fc0de6a610765f14663e6dd0dc04eb86713ed3ed69e634984fbefccc0 artifacts/native-session-manifest.json
+eeaee3411588315d22bea3b5bc6d49801c42e562022ffda382fe63f8bd0be1e7 artifacts/native-session.har
+0c322878c3ac124fe8cfeff269ef8ed87a15fbfc81a59639611f9a036a260390 manifest.json
+fa7a9fee5c940e725c37414bce03dba5a349e43a13d1a230750657e22626a083 manual-cli-case.sh
+b0f2501a432a19513d19883b7d2efd83dbdaa451a3a9eda1fcfe06131469ec1e manual-cli-evidence-server.mjs
+46f81eb2930040a31f402cf4cb113758628eb72c9db27617babba1d3df31af27 manual-evidence-server.mjs
+7975bd338de114fc682a4942688496fa0d53945ef400a7b4069f748b0ad0d18b manual-localhost-cert.pem
+454d203a290b11eda450af412047157a1cdf142cadf4c7a6c2c59b59776f38fb manual-localhost-key.pem
+5a16264481f0c09e1db3200432be162acddc7573b5053b35f2ff0eb327878cfc manual-native-http2-probe.mjs
+99e0983a5ee31178beecddf0b8b10f19ba7f04b36ed8b4fbe8b7b93527d1fdd6 manual-native-mock-conflict.mjs
+0d1df924aaef8eac60d7c60b77d55e3856c51c9e32d1878888afcf45a2a2df16 screenshots/MT-01-cli-version-doctor-native.png
+45c90d44ead071c0821f41e260c634ad48bd50e161a25d226b550936f8008b20 screenshots/MT-02-cli-zero-code-and-conflict.png
+c2031cce1cfff68bfa763aef8839c7729d1b54a2cd8f31a8fdebcb2db4cfbe71 screenshots/MT-03-native-runtime-and-capabilities.png
+8f31650d6374c3ab49fed9fa49301b99bb57a41e5b3b0758953b4a12081466f5 screenshots/MT-04-native-version.png
+072e937ab5a7de08f150fd5fdc98294cc71fc87ac60da59596c309655df619e1 screenshots/MT-05-native-devtools-network-list.png
+dc3ec6a367ca6143772eb4bc4b71db44b11524cd737b4e0ab1e4f8f9142bc391 screenshots/MT-05-native-fetch-headers.png
+da6d4cbd7f5158605788007086ddb49c259cf1e60a06c2eb8495c97e60053616 screenshots/MT-05-native-fetch-response.png
+2c6024dd1510383b7fd6c22ebef6f45e9cebac39911b0543f1ef1ebf73d07ce9 screenshots/MT-06-native-devtools-reload-reconnect.png
+16b537e7df8e9ec90ec175c55e8d196dc10dea2d96f6cbe29c153a8561557be6 screenshots/MT-07-legacy-runtime-and-capabilities.png
+51f37216e97186cda052b151dc95941d8644a9316a676df7362598e61bdb637f screenshots/MT-08-legacy-version.png
+1908657aa0ee8a8e52173c247994a28e8334a464b93b4296e569ab7384d28d34 screenshots/MT-09-legacy-devtools-network-list.png
+dc9e4be668ef3af1ffef8ec3885ac65497d0fdf7776572dc50c81e43937886d2 screenshots/MT-09-legacy-fetch-payload.png
+6c3c5e61cc01c52c0c360c7c0a5a8c96af35d19af56bf0b815b2dc15933c39e0 screenshots/MT-09-legacy-fetch-response.png
+fcaa05f2189a06149dfeb9c33fa8e7e15f2a58ff5bc6619e1a7fc7d0c6cf429a screenshots/MT-10-legacy-mock-fetch-response.png
+79b0877b95f98d1f33e9c909d05127d3e0281ae2bce9ca5f01b5e02dde0daf74 screenshots/MT-10-legacy-mock-http-response.png
+ee7ba9a6291e7cb424dbbd987625f2359c6a74c7ef65810f8c9ce3fbe5a12aa5 screenshots/MT-11-legacy-sse-eventstream.png
+502304fb3ebb7fe3cb5de7b1246a7fc495963a6b89223f4f275dd8816bfd320d screenshots/MT-11-legacy-websocket-messages.png
+ff34e249b69ead76153c195a8508faa120db07aebbfe54cf533442d838012720 screenshots/MT-11-native-websocket-lifecycle.png
+3eb522a9f6a3e0d677899d88a6bb5823ebdc899c9c5d687ce1c37706d3e55df6 screenshots/MT-12-legacy-session-har-replay-trace.png
+95be16feaaab913174c623d0bf79c0730f16a0117254add4495b3c714d06d8c4 screenshots/MT-12-native-session-har-replay-trace.png
+0e5297005a8e58f18e4626641ace09f24d08d37c84ad69886af21dbc06235011 screenshots/MT-13-legacy-boundary-assertions.png
+fd2338052112843746471657b006d8392b73f9f9e04850be03a7a73aa663ec09 screenshots/MT-13-legacy-failed-request.png
+a9c3c8df80a4447b35edcd66102c692624675378f860e2bceceeeb669309f99f screenshots/MT-13-native-boundary-assertions.png
+f0ce25b01a86e27d509ba14b0ca3ed5b9256ede0f4bc776e7a21ddcc98901885 screenshots/MT-13-native-failed-request.png
+fb8aba5f739fc8ae4803ffaf516606c7e313124a62fcad96c01c876cfd1943a7 screenshots/MT-14-legacy-dispose-cleanup.png
+ae56050e91183a777974c7d9e14493239d51e5ace2146fd43ae1be440066b5c0 screenshots/MT-14-native-dispose-cleanup.png
diff --git a/output/playwright/pr-64/manifest.json b/output/playwright/pr-64/manifest.json
new file mode 100644
index 0000000..3e38816
--- /dev/null
+++ b/output/playwright/pr-64/manifest.json
@@ -0,0 +1,193 @@
+{
+ "schemaVersion": 1,
+ "testRunId": "pr-64-manual-acceptance-2026-08-28",
+ "pullRequest": "https://github.com/GrinZero/node-network-devtools/pull/64",
+ "testedProductCommit": "449d47db89109e826eb0e7e0584777365eac3f9b",
+ "package": {
+ "name": "node-network-devtools",
+ "version": "2.0.0",
+ "packedFileCount": 190,
+ "tarballSha256": "e97dc360d2fcd5d141b13bca490f603b802dc7fe94f3b6a06a690c7a7f48e2ac"
+ },
+ "environment": {
+ "node": "v24.16.0",
+ "compatibilityProbeNodes": ["v22.22.3", "v24.16.0", "v26.8.1"],
+ "platform": "darwin-arm64",
+ "browser": "Chrome for Testing 151.0.7922.34",
+ "playwrightCli": "0.1.18",
+ "playwrightEngine": "1.63.0-alpha-2026-08-05",
+ "timezone": "Asia/Shanghai",
+ "date": "2026-08-28"
+ },
+ "harness": {
+ "manualEvidenceServerSha256": "46f81eb2930040a31f402cf4cb113758628eb72c9db27617babba1d3df31af27"
+ },
+ "result": {
+ "status": "PASS",
+ "passed": 14,
+ "failed": 0,
+ "total": 14,
+ "screenshots": 26,
+ "structuredArtifacts": 17,
+ "reproductionHarnesses": 5,
+ "testTlsInputs": 2
+ },
+ "cases": [
+ {
+ "id": "MT-01",
+ "title": "Exact package version and Native doctor",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-01-cli-version-doctor-native.png",
+ "artifacts/cli-manual-results.json"
+ ]
+ },
+ {
+ "id": "MT-02",
+ "title": "Zero-code CLI modes, conflict, replay, and exact-target open",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-02-cli-zero-code-and-conflict.png",
+ "artifacts/cli-manual-results.json"
+ ]
+ },
+ {
+ "id": "MT-03",
+ "title": "Native registration and capability disclosure",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-03-native-runtime-and-capabilities.png",
+ "artifacts/native-runtime.json"
+ ]
+ },
+ {
+ "id": "MT-04",
+ "title": "Native standard CDP discovery contract",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-03-native-runtime-and-capabilities.png",
+ "screenshots/MT-04-native-version.png",
+ "artifacts/native-runtime.json"
+ ]
+ },
+ {
+ "id": "MT-05",
+ "title": "Native HTTP, HTTPS, and Fetch in official DevTools",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-05-native-devtools-network-list.png",
+ "screenshots/MT-05-native-fetch-headers.png",
+ "screenshots/MT-05-native-fetch-response.png",
+ "artifacts/native-events.ndjson"
+ ]
+ },
+ {
+ "id": "MT-06",
+ "title": "Native DevTools reload and reconnect",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-06-native-devtools-reload-reconnect.png",
+ "artifacts/native-session-manifest.json"
+ ]
+ },
+ {
+ "id": "MT-07",
+ "title": "Auto-to-Legacy selection and capability disclosure",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-07-legacy-runtime-and-capabilities.png",
+ "artifacts/legacy-runtime.json"
+ ]
+ },
+ {
+ "id": "MT-08",
+ "title": "Legacy standard CDP discovery contract",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-07-legacy-runtime-and-capabilities.png",
+ "screenshots/MT-08-legacy-version.png",
+ "artifacts/legacy-runtime.json"
+ ]
+ },
+ {
+ "id": "MT-09",
+ "title": "Legacy HTTP, HTTPS, and Fetch details in official DevTools",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-09-legacy-devtools-network-list.png",
+ "screenshots/MT-09-legacy-fetch-payload.png",
+ "screenshots/MT-09-legacy-fetch-response.png",
+ "artifacts/legacy-events.ndjson"
+ ]
+ },
+ {
+ "id": "MT-10",
+ "title": "Legacy HTTP and Fetch mocking with zero origin leakage",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-10-legacy-mock-http-response.png",
+ "screenshots/MT-10-legacy-mock-fetch-response.png",
+ "artifacts/legacy-finalize-summary.json"
+ ]
+ },
+ {
+ "id": "MT-11",
+ "title": "Native and Legacy SSE/WebSocket capability boundaries",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-11-native-websocket-lifecycle.png",
+ "screenshots/MT-11-legacy-sse-eventstream.png",
+ "screenshots/MT-11-legacy-websocket-messages.png",
+ "artifacts/native-finalize-summary.json",
+ "artifacts/legacy-finalize-summary.json"
+ ]
+ },
+ {
+ "id": "MT-12",
+ "title": "Session, HAR, Replay, and Trace on both backends",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-12-native-session-har-replay-trace.png",
+ "screenshots/MT-12-legacy-session-har-replay-trace.png",
+ "artifacts/cli-manual-results.json",
+ "artifacts/native-finalize-summary.json",
+ "artifacts/legacy-finalize-summary.json",
+ "artifacts/native-session.har",
+ "artifacts/legacy-session.har"
+ ]
+ },
+ {
+ "id": "MT-13",
+ "title": "Failed lifecycle and Native HTTP/2 capability boundary",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-13-native-failed-request.png",
+ "screenshots/MT-13-legacy-failed-request.png",
+ "screenshots/MT-13-native-boundary-assertions.png",
+ "screenshots/MT-13-legacy-boundary-assertions.png",
+ "artifacts/native-http2-probe.json",
+ "artifacts/native-finalize-summary.json",
+ "artifacts/legacy-finalize-summary.json"
+ ]
+ },
+ {
+ "id": "MT-14",
+ "title": "Dispose and closed-endpoint verification",
+ "status": "PASS",
+ "evidence": [
+ "screenshots/MT-14-native-dispose-cleanup.png",
+ "screenshots/MT-14-legacy-dispose-cleanup.png",
+ "artifacts/native-dispose-summary.json",
+ "artifacts/legacy-dispose-summary.json"
+ ]
+ }
+ ],
+ "notes": [
+ "All business traffic was loopback-only.",
+ "Native requestBody, websocketFrames, and sseMessages remain false on the primary Node 24 run and were not represented as supported.",
+ "Native HTTP/2 is advertised only on the verified Node 22.20+ 22.x line; the expected Node 24/26 upstream crash reproductions prove why the capability is withheld there.",
+ "The CLI --open OS launch was redirected to an exact-target CDP verifier; separate screenshots prove a real visible connection to the official Chromium DevTools frontend.",
+ "Local filesystem prefixes were redacted from retained text, and raw discovery screenshots containing local file URLs were excluded.",
+ "Screenshots satisfy the requested visual-evidence option; no video is committed."
+ ]
+}
diff --git a/output/playwright/pr-64/manual-cli-case.sh b/output/playwright/pr-64/manual-cli-case.sh
new file mode 100644
index 0000000..1582ab5
--- /dev/null
+++ b/output/playwright/pr-64/manual-cli-case.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+backend="${1:-}"
+if [[ "$backend" != "native" && "$backend" != "legacy" ]]; then
+ echo "usage: bash manual-cli-case.sh " >&2
+ exit 2
+fi
+
+evidence_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$evidence_root/../../.." && pwd)"
+nnd="$evidence_root/consumer/node_modules/.bin/nnd"
+fixture="$repo_root/packages/network-debugger/test/e2e/cli/fixtures/probe.mjs"
+
+"$nnd" dev --no-wait --mode "$backend" "$fixture" 2>/dev/null \
+ | sed -n 's/^@@NND_E2E@@//p' \
+ | jq '{type,label,preloadInjected,mode,execArgv,capabilities}'
diff --git a/output/playwright/pr-64/manual-cli-evidence-server.mjs b/output/playwright/pr-64/manual-cli-evidence-server.mjs
new file mode 100644
index 0000000..f47ca6f
--- /dev/null
+++ b/output/playwright/pr-64/manual-cli-evidence-server.mjs
@@ -0,0 +1,456 @@
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const evidenceRoot = dirname(fileURLToPath(import.meta.url))
+const repoRoot = resolve(evidenceRoot, '../../..')
+const nndPath = resolve(evidenceRoot, 'consumer/node_modules/.bin/nnd')
+const fixturePath = resolve(repoRoot, 'packages/network-debugger/test/e2e/cli/fixtures/probe.mjs')
+const conflictPath = resolve(evidenceRoot, 'manual-native-mock-conflict.mjs')
+const artifactPath = resolve(evidenceRoot, 'artifacts/cli-manual-results.json')
+const replayFixturePath = resolve(evidenceRoot, 'artifacts/cli-replay-fixture.har')
+const openRecordPath = resolve(evidenceRoot, '.runtime/cli-open.ndjson')
+const frontendOpenHookPath = resolve(
+ repoRoot,
+ 'packages/network-debugger/test/e2e/cli/fixtures/frontend-open-hook.cjs'
+)
+const frontendRunnerPath = resolve(
+ repoRoot,
+ 'packages/network-debugger/test/e2e/cli/fixtures/frontend-cdp-runner.mjs'
+)
+const productCommit = process.env.NND_MANUAL_PRODUCT_COMMIT ?? 'unknown'
+const tarballSha256 = process.env.NND_MANUAL_TARBALL_SHA256 ?? 'unknown'
+
+const results = new Map()
+const replayOriginRecords = []
+let baseUrl
+
+const pathRedactions = [
+ [resolve(evidenceRoot, 'consumer'), ''],
+ [evidenceRoot, ''],
+ [repoRoot, '']
+]
+
+function redactEvidence(value) {
+ if (typeof value === 'string') {
+ return pathRedactions.reduce(
+ (redacted, [path, replacement]) => redacted.replaceAll(path, replacement),
+ value
+ )
+ }
+ if (Array.isArray(value)) return value.map(redactEvidence)
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value).map(([name, entry]) => [name, redactEvidence(entry)])
+ )
+ }
+ return value
+}
+
+function run(command, args, { env = {}, timeoutMs = 20_000 } = {}) {
+ return new Promise((resolveRun, rejectRun) => {
+ const child = spawn(command, args, {
+ cwd: repoRoot,
+ env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', ...env },
+ stdio: ['ignore', 'pipe', 'pipe']
+ })
+ let stdout = ''
+ let stderr = ''
+ child.stdout.setEncoding('utf8')
+ child.stderr.setEncoding('utf8')
+ child.stdout.on('data', (chunk) => (stdout += chunk))
+ child.stderr.on('data', (chunk) => (stderr += chunk))
+ const timeout = setTimeout(() => {
+ child.kill('SIGTERM')
+ rejectRun(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`))
+ }, timeoutMs)
+ child.once('error', (error) => {
+ clearTimeout(timeout)
+ rejectRun(error)
+ })
+ child.once('close', (exitCode, signal) => {
+ clearTimeout(timeout)
+ resolveRun({ exitCode, signal, stdout: stdout.trim(), stderr: stderr.trim() })
+ })
+ })
+}
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message)
+}
+
+function parseFixtureMarker(stdout, label) {
+ const marker = stdout.split('\n').find((line) => line.startsWith('@@NND_E2E@@'))
+ assert(marker, `${label} fixture marker missing`)
+ return JSON.parse(marker.slice('@@NND_E2E@@'.length))
+}
+
+async function waitForFile(path, timeoutMs = 5_000) {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ try {
+ return await readFile(path, 'utf8')
+ } catch (error) {
+ if (error?.code !== 'ENOENT') throw error
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 25))
+ }
+ }
+ throw new Error(`Timed out waiting for ${path}`)
+}
+
+async function writeReplayFixture() {
+ const har = {
+ log: {
+ version: '1.2',
+ creator: { name: 'PR #64 manual evidence', version: '1' },
+ pages: [],
+ entries: [
+ {
+ request: {
+ method: 'GET',
+ url: `${baseUrl}/replay-get?case=cli-public-replay`,
+ httpVersion: 'HTTP/1.1',
+ headers: [],
+ queryString: [{ name: 'case', value: 'cli-public-replay' }],
+ cookies: [],
+ headersSize: -1,
+ bodySize: 0
+ },
+ response: {},
+ cache: {},
+ timings: {},
+ _requestId: 'cli-replay-get'
+ },
+ {
+ request: {
+ method: 'POST',
+ url: `${baseUrl}/replay-post?case=cli-public-replay`,
+ httpVersion: 'HTTP/1.1',
+ headers: [{ name: 'content-type', value: 'text/plain; charset=utf-8' }],
+ queryString: [{ name: 'case', value: 'cli-public-replay' }],
+ cookies: [],
+ headersSize: -1,
+ bodySize: 22,
+ postData: {
+ mimeType: 'text/plain; charset=utf-8',
+ text: 'cli-replay-request-body'
+ }
+ },
+ response: {},
+ cache: {},
+ timings: {},
+ _requestId: 'cli-replay-post'
+ }
+ ]
+ }
+ }
+ await mkdir(dirname(replayFixturePath), { recursive: true })
+ await writeFile(replayFixturePath, `${JSON.stringify(har, null, 2)}\n`, 'utf8')
+}
+
+async function runCase(id) {
+ if (id === 'doctor') {
+ const versionExecution = await run(process.execPath, [nndPath, '--version'])
+ assert(versionExecution.exitCode === 0, `version exited ${versionExecution.exitCode}`)
+ const version = versionExecution.stdout.trim()
+ assert(version === '2.0.0', `version must be 2.0.0, got ${version}`)
+ const execution = await run(process.execPath, [
+ '--experimental-network-inspection',
+ nndPath,
+ 'doctor',
+ '--json'
+ ])
+ assert(execution.exitCode === 0, `doctor exited ${execution.exitCode}`)
+ const value = JSON.parse(execution.stdout)
+ assert(value.ok === true, 'doctor ok must be true')
+ assert(value.packageVersion === '2.0.0', 'packageVersion must be 2.0.0')
+ assert(value.experimentalFlag === true, 'experimental flag must be detected')
+ assert(value.selection?.selected === 'native', 'doctor must select Native')
+ assert(value.networkMethods?.missingRequired?.length === 0, 'required methods must exist')
+ return {
+ id,
+ title: 'CLI version + Native doctor',
+ status: 'PASS',
+ command: `nnd --version && node --experimental-network-inspection ${nndPath} doctor --json`,
+ actual: {
+ version,
+ schemaVersion: value.schemaVersion,
+ ok: value.ok,
+ nodeVersion: value.nodeVersion,
+ packageVersion: value.packageVersion,
+ experimentalFlag: value.experimentalFlag,
+ missingRequired: value.networkMethods.missingRequired,
+ selection: value.selection,
+ capabilities: value.capabilities
+ }
+ }
+ }
+
+ if (id === 'native' || id === 'legacy') {
+ const execution = await run(process.execPath, [
+ nndPath,
+ 'dev',
+ '--no-wait',
+ '--mode',
+ id,
+ fixturePath
+ ])
+ assert(execution.exitCode === 0, `${id} dev exited ${execution.exitCode}`)
+ const value = parseFixtureMarker(execution.stdout, id)
+ assert(value.preloadInjected === true, `${id} preload must be injected`)
+ assert(value.mode === id, `${id} selected mode mismatch`)
+ assert(value.target?.discoveryUrl, `${id} discovery URL missing`)
+ return {
+ id,
+ title: `Zero-code nnd dev · ${id.toUpperCase()}`,
+ status: 'PASS',
+ command: `nnd dev --no-wait --mode ${id} ${fixturePath}`,
+ actual: {
+ type: value.type,
+ preloadInjected: value.preloadInjected,
+ mode: value.mode,
+ execArgv: value.execArgv,
+ target: value.target,
+ capabilities: value.capabilities
+ }
+ }
+ }
+
+ if (id === 'conflict') {
+ const execution = await run(process.execPath, [conflictPath])
+ assert(execution.exitCode === 0, `conflict check exited ${execution.exitCode}`)
+ const value = JSON.parse(execution.stdout)
+ assert(value.code === 'NND_NATIVE_MOCK_CONFLICT', 'explicit conflict code missing')
+ return {
+ id,
+ title: 'Forced Native + Mock conflict',
+ status: 'PASS',
+ command: `node ${conflictPath}`,
+ actual: value
+ }
+ }
+
+ if (id === 'replay') {
+ await writeReplayFixture()
+ replayOriginRecords.length = 0
+ const dryExecution = await run(process.execPath, [
+ nndPath,
+ 'replay',
+ '--dry-run',
+ '--json',
+ replayFixturePath
+ ])
+ assert(dryExecution.exitCode === 0, `replay dry-run exited ${dryExecution.exitCode}`)
+ const dryRun = JSON.parse(dryExecution.stdout)
+ assert(dryRun.dryRun === true, 'CLI replay dry-run flag missing')
+ assert(dryRun.succeeded === 2 && dryRun.failed === 0, 'CLI replay dry-run did not pass 2/2')
+ assert(replayOriginRecords.length === 0, 'CLI replay dry-run performed network I/O')
+
+ const realExecution = await run(process.execPath, [
+ nndPath,
+ 'replay',
+ '--json',
+ '--timeout',
+ '5000',
+ replayFixturePath
+ ])
+ assert(realExecution.exitCode === 0, `real replay exited ${realExecution.exitCode}`)
+ const real = JSON.parse(realExecution.stdout)
+ assert(real.dryRun === false, 'real CLI replay was unexpectedly dry')
+ assert(real.succeeded === 2 && real.failed === 0, 'real CLI replay did not pass 2/2')
+ assert(replayOriginRecords.length === 2, 'real CLI replay did not reach both endpoints')
+ assert(
+ replayOriginRecords.some(
+ (record) => record.method === 'POST' && record.body === 'cli-replay-request-body'
+ ),
+ 'real CLI replay did not preserve the POST body'
+ )
+ return {
+ id,
+ title: 'Public nnd replay · dry + real',
+ status: 'PASS',
+ command: `nnd replay --dry-run --json ${replayFixturePath} && nnd replay --json ${replayFixturePath}`,
+ actual: { dryRun, real, originRequests: [...replayOriginRecords] }
+ }
+ }
+
+ if (id === 'open') {
+ await mkdir(dirname(openRecordPath), { recursive: true })
+ await rm(openRecordPath, { force: true })
+ const execution = await run(
+ process.execPath,
+ [
+ '--require',
+ frontendOpenHookPath,
+ nndPath,
+ 'dev',
+ '--open',
+ '--mode',
+ 'native',
+ fixturePath
+ ],
+ {
+ env: {
+ NND_E2E_FRONTEND_RUNNER: frontendRunnerPath,
+ NND_E2E_FRONTEND_RECORD: openRecordPath
+ }
+ }
+ )
+ assert(execution.exitCode === 0, `nnd dev --open exited ${execution.exitCode}`)
+ const value = parseFixtureMarker(execution.stdout, 'open')
+ const opened = (await waitForFile(openRecordPath))
+ .trim()
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .map((line) => JSON.parse(line))
+ assert(value.preloadInjected === true, 'open case preload must be injected')
+ assert(value.mode === 'native', 'open case must select Native')
+ assert(opened.length === 1, `authoritative target opened ${opened.length} times`)
+ assert(
+ opened[0].webSocketDebuggerUrl === value.target.webSocketDebuggerUrl,
+ 'opened socket does not match the CLI target'
+ )
+ assert(opened[0].frontendUrl.startsWith('devtools://'), 'opened URL is not DevTools')
+ return {
+ id,
+ title: 'nnd dev --open · authoritative target',
+ status: 'PASS',
+ command: `nnd dev --open --mode native ${fixturePath}`,
+ actual: {
+ preloadInjected: value.preloadInjected,
+ mode: value.mode,
+ inspectWait: value.execArgv.find((argument) => argument.startsWith('--inspect-wait=')),
+ target: value.target,
+ openedExactlyOnce: opened.length === 1,
+ openedFrontend: opened[0],
+ launcherVerification: 'OS browser spawn redirected to an exact-target CDP verifier'
+ }
+ }
+ }
+
+ throw new Error(`Unknown case: ${id}`)
+}
+
+async function saveResults() {
+ await mkdir(dirname(artifactPath), { recursive: true })
+ await writeFile(
+ artifactPath,
+ `${JSON.stringify(
+ {
+ schemaVersion: 1,
+ productCommit,
+ tarballSha256,
+ cases: [...results.values()]
+ },
+ null,
+ 2
+ )}\n`
+ )
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+function render() {
+ const definitions = [
+ ['doctor', 'CLI version + doctor'],
+ ['native', 'Zero-code Native'],
+ ['legacy', 'Zero-code Legacy'],
+ ['conflict', 'Native + Mock conflict'],
+ ['replay', 'Public replay dry + real'],
+ ['open', 'CLI --open authoritative target']
+ ]
+ const cards = definitions
+ .map(([id, title]) => {
+ const result = results.get(id)
+ return `
+ ${escapeHtml(title)}
${result ? 'PASS' : 'PENDING'}
+ ${
+ result
+ ? `${escapeHtml(result.command)}${escapeHtml(JSON.stringify(result.actual, null, 2))}`
+ : `Click the case button to run the exact packed package.
`
+ }
+ `
+ })
+ .join('')
+
+ return `
+
+ PR #64 manual CLI evidence
+ PR #64 · manual acceptance
Exact-package CLI evidence
+ ${results.size}/${definitions.length} cases passed · each button launches the installed tarball, not workspace source.
+ Product commit ${escapeHtml(productCommit)}
Package node-network-devtools@2.0.0
Tarball SHA-256 ${escapeHtml(tarballSha256)}
Runner ${escapeHtml(process.version)} · ${escapeHtml(process.platform)}-${escapeHtml(process.arch)}
+ ${definitions.map(([id, title]) => ``).join('')}
+
+ `
+}
+
+const server = createServer(async (request, response) => {
+ if (request.url?.startsWith('/replay-get')) {
+ replayOriginRecords.push({ method: request.method, url: request.url, body: '' })
+ response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
+ response.end('cli-replay-get-ok')
+ return
+ }
+
+ if (request.url?.startsWith('/replay-post')) {
+ const chunks = []
+ for await (const chunk of request) chunks.push(Buffer.from(chunk))
+ const body = Buffer.concat(chunks).toString('utf8')
+ replayOriginRecords.push({ method: request.method, url: request.url, body })
+ response.writeHead(201, { 'content-type': 'application/json; charset=utf-8' })
+ response.end(JSON.stringify({ ok: true, body }))
+ return
+ }
+
+ if (request.method === 'POST' && request.url?.startsWith('/run/')) {
+ const id = request.url.slice('/run/'.length)
+ try {
+ results.set(id, redactEvidence(await runCase(id)))
+ await saveResults()
+ response.writeHead(204).end()
+ } catch (error) {
+ response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
+ response.end(error?.stack ?? String(error))
+ }
+ return
+ }
+
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
+ response.end(render())
+})
+
+server.listen(0, '127.0.0.1', () => {
+ const address = server.address()
+ baseUrl = `http://127.0.0.1:${address.port}`
+ console.log(`NND_CLI_MANUAL_READY ${baseUrl}`)
+})
+
+process.once('SIGINT', () => server.close(() => process.exit(0)))
+process.once('SIGTERM', () => server.close(() => process.exit(0)))
diff --git a/output/playwright/pr-64/manual-evidence-server.mjs b/output/playwright/pr-64/manual-evidence-server.mjs
new file mode 100644
index 0000000..b8e299e
--- /dev/null
+++ b/output/playwright/pr-64/manual-evidence-server.mjs
@@ -0,0 +1,1063 @@
+import { spawn } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import http from 'node:http'
+import https from 'node:https'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
+
+const backend = process.argv[2]
+if (!['native', 'legacy'].includes(backend)) {
+ throw new Error('Usage: node manual-evidence-server.mjs ')
+}
+
+const consumerRoot = resolve(
+ process.env.NND_MANUAL_CONSUMER_ROOT ?? new URL('./consumer', import.meta.url).pathname
+)
+const evidenceRoot = resolve(
+ process.env.NND_MANUAL_EVIDENCE_ROOT ?? new URL('.', import.meta.url).pathname
+)
+const productCommit = process.env.NND_MANUAL_PRODUCT_COMMIT ?? 'unknown'
+const tarballSha256 = process.env.NND_MANUAL_TARBALL_SHA256 ?? 'unknown'
+const runtimeRoot = join(evidenceRoot, '.runtime')
+const artifactsRoot = join(evidenceRoot, 'artifacts')
+const sessionDirectory = join(runtimeRoot, `${backend}-session-${Date.now()}`)
+const manualRequire = createRequire(join(consumerRoot, 'consumer.cjs'))
+const workspaceRequire = createRequire(
+ new URL('../../../packages/network-debugger/package.json', import.meta.url)
+)
+const api = manualRequire('node-network-devtools')
+const { chromium } = workspaceRequire('@playwright/test')
+const WebSocket = manualRequire('ws')
+const { WebSocketServer } = WebSocket
+const packageDirectory = resolve(dirname(manualRequire.resolve('node-network-devtools')), '..')
+const packageManifest = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8'))
+const { register, SessionRecorder, exportHar, replay } = api
+
+for (const [name, value] of Object.entries({
+ register,
+ SessionRecorder,
+ exportHar,
+ replay
+})) {
+ if (typeof value !== 'function') {
+ throw new Error(`The exact packed package does not export ${name}`)
+ }
+}
+
+await mkdir(runtimeRoot, { recursive: true })
+await mkdir(artifactsRoot, { recursive: true })
+
+function sha256(value) {
+ return createHash('sha256').update(value).digest('hex')
+}
+
+const pathRedactions = [
+ [consumerRoot, ''],
+ [evidenceRoot, '']
+]
+
+function redactText(value) {
+ return pathRedactions.reduce(
+ (redacted, [path, replacement]) => redacted.replaceAll(path, replacement),
+ value
+ )
+}
+
+function redactEvidence(value) {
+ if (typeof value === 'string') return redactText(value)
+ if (Array.isArray(value)) return value.map(redactEvidence)
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value).map(([name, entry]) => [name, redactEvidence(entry)])
+ )
+ }
+ return value
+}
+
+function jsonResponse(response, status, value) {
+ const body = `${JSON.stringify(value, null, 2)}\n`
+ response.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'cache-control': 'no-store'
+ })
+ response.end(body)
+}
+
+function listen(server) {
+ return new Promise((resolvePromise, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => {
+ server.off('error', reject)
+ const address = server.address()
+ if (!address || typeof address === 'string') {
+ reject(new Error('Server did not expose a TCP address'))
+ return
+ }
+ resolvePromise(address.port)
+ })
+ })
+}
+
+function closeServer(server, sockets) {
+ if (!server.listening) return Promise.resolve()
+ return new Promise((resolvePromise) => {
+ server.close(() => resolvePromise())
+ for (const socket of sockets) socket.destroy()
+ })
+}
+
+function httpRequest(url, { method = 'GET', headers = {}, body } = {}) {
+ return new Promise((resolvePromise, reject) => {
+ const secure = new URL(url).protocol === 'https:'
+ const request = (secure ? https : http).request(
+ url,
+ { method, headers, ...(secure ? { rejectUnauthorized: false } : {}) },
+ (response) => {
+ const chunks = []
+ response.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
+ response.once('error', reject)
+ response.once('end', () => {
+ resolvePromise({
+ status: response.statusCode,
+ statusText: response.statusMessage,
+ headers: response.headers,
+ body: Buffer.concat(chunks).toString('utf8')
+ })
+ })
+ }
+ )
+ request.once('error', reject)
+ request.end(body)
+ })
+}
+
+function webSocketRoundTrip(url, token) {
+ return new Promise((resolvePromise, reject) => {
+ const socket = new WebSocket(url)
+ const messages = []
+ socket.once('error', reject)
+ socket.once('open', () => {
+ socket.send(`client-text:${token}`)
+ socket.send(Buffer.from([0, 1, 2, 127, 128, 254, 255]))
+ })
+ socket.on('message', (data, isBinary) => {
+ messages.push({
+ isBinary,
+ value: isBinary ? Buffer.from(data).toString('base64') : Buffer.from(data).toString('utf8')
+ })
+ if (messages.length === 2) socket.close(1000, 'manual-evidence-complete')
+ })
+ socket.once('close', (code, reason) =>
+ resolvePromise({ code, reason: reason.toString(), messages })
+ )
+ })
+}
+
+function nativeWebSocketRoundTrip(url, token) {
+ return new Promise((resolvePromise, reject) => {
+ const socket = new globalThis.WebSocket(url)
+ socket.binaryType = 'arraybuffer'
+ const messages = []
+ socket.addEventListener('error', () => reject(new Error('Native WebSocket failed')))
+ socket.addEventListener('open', () => {
+ socket.send(`client-text:${token}`)
+ socket.send(new Uint8Array([0, 1, 2, 127, 128, 254, 255]))
+ })
+ socket.addEventListener('message', (event) => {
+ const isBinary = typeof event.data !== 'string'
+ const value = isBinary ? Buffer.from(event.data).toString('base64') : event.data
+ messages.push({ isBinary, value })
+ if (messages.length === 2) socket.close(1000, 'manual-evidence-complete')
+ })
+ socket.addEventListener('close', (event) =>
+ resolvePromise({ code: event.code, reason: event.reason, messages })
+ )
+ })
+}
+
+const originSockets = new Set()
+const originRecords = []
+const webSocketServer = new WebSocketServer({ noServer: true })
+const originServer = http.createServer(async (request, response) => {
+ const url = new URL(request.url, `http://${request.headers.host ?? '127.0.0.1'}`)
+ const chunks = []
+ for await (const chunk of request) chunks.push(Buffer.from(chunk))
+ const requestBody = Buffer.concat(chunks).toString('utf8')
+ const record = {
+ sequence: originRecords.length + 1,
+ timestamp: new Date().toISOString(),
+ method: request.method,
+ pathname: url.pathname,
+ url: url.href,
+ requestBody,
+ traceparent: request.headers.traceparent ?? null,
+ tracestate: request.headers.tracestate ?? null
+ }
+ originRecords.push(record)
+ const token = url.searchParams.get('token') ?? ''
+
+ if (url.pathname === '/get') {
+ const body = `manual-get-response:${token}`
+ response.writeHead(200, {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'x-manual-case': token
+ })
+ response.end(body)
+ return
+ }
+
+ if (url.pathname === '/post') {
+ const body = JSON.stringify({ token, requestBody })
+ response.writeHead(201, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'x-manual-case': token
+ })
+ response.end(body)
+ return
+ }
+
+ if (url.pathname === '/sse') {
+ const body = `id: 1\nevent: manual\ndata: sse-${token}\n\nid: 2\ndata: complete-${token}\n\n`
+ response.writeHead(200, {
+ 'content-type': 'text/event-stream; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'cache-control': 'no-cache'
+ })
+ response.end(body)
+ return
+ }
+
+ if (url.pathname === '/trace') {
+ const body = JSON.stringify({
+ token,
+ traceparent: request.headers.traceparent,
+ tracestate: request.headers.tracestate
+ })
+ response.writeHead(200, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'content-length': Buffer.byteLength(body)
+ })
+ response.end(body)
+ return
+ }
+
+ if (url.pathname === '/reset') {
+ request.socket.destroy()
+ return
+ }
+
+ if (url.pathname.startsWith('/mock-')) {
+ const body = `ORIGIN_LEAK:${url.pathname}`
+ response.writeHead(599, {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'content-length': Buffer.byteLength(body)
+ })
+ response.end(body)
+ return
+ }
+
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
+ response.end('not found')
+})
+originServer.on('connection', (socket) => {
+ originSockets.add(socket)
+ socket.once('close', () => originSockets.delete(socket))
+})
+originServer.on('upgrade', (request, socket, head) => {
+ const url = new URL(request.url, `http://${request.headers.host ?? '127.0.0.1'}`)
+ if (url.pathname !== '/websocket') {
+ socket.destroy()
+ return
+ }
+ originRecords.push({
+ sequence: originRecords.length + 1,
+ timestamp: new Date().toISOString(),
+ method: request.method,
+ pathname: url.pathname,
+ url: url.href,
+ requestBody: '',
+ traceparent: null,
+ tracestate: null
+ })
+ webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
+ webSocketServer.emit('connection', webSocket, request)
+ })
+})
+webSocketServer.on('connection', (socket) => {
+ socket.on('message', (data, isBinary) => socket.send(data, { binary: isBinary }))
+})
+const originPort = await listen(originServer)
+const originBaseUrl = `http://127.0.0.1:${originPort}`
+
+const secureOriginSockets = new Set()
+const secureOriginServer = https.createServer(
+ {
+ key: await readFile(new URL('./manual-localhost-key.pem', import.meta.url)),
+ cert: await readFile(new URL('./manual-localhost-cert.pem', import.meta.url))
+ },
+ async (request, response) => {
+ const url = new URL(request.url, `https://${request.headers.host ?? '127.0.0.1'}`)
+ const chunks = []
+ for await (const chunk of request) chunks.push(Buffer.from(chunk))
+ const requestBody = Buffer.concat(chunks).toString('utf8')
+ const token = url.searchParams.get('token') ?? ''
+ originRecords.push({
+ sequence: originRecords.length + 1,
+ timestamp: new Date().toISOString(),
+ method: request.method,
+ pathname: url.pathname,
+ url: url.href,
+ requestBody,
+ traceparent: request.headers.traceparent ?? null,
+ tracestate: request.headers.tracestate ?? null
+ })
+ if (url.pathname !== '/secure-get') {
+ response.writeHead(404).end('not found')
+ return
+ }
+ const body = `manual-https-response:${token}`
+ response.writeHead(200, {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'x-manual-case': token
+ })
+ response.end(body)
+ }
+)
+secureOriginServer.on('connection', (socket) => {
+ secureOriginSockets.add(socket)
+ socket.once('close', () => secureOriginSockets.delete(socket))
+})
+const secureOriginPort = await listen(secureOriginServer)
+const secureOriginBaseUrl = `https://127.0.0.1:${secureOriginPort}`
+
+const mockRules = [
+ {
+ id: 'manual-http-mock',
+ match: { url: `${originBaseUrl}/mock-http*`, method: 'GET' },
+ response: {
+ status: 207,
+ statusText: 'Manual Mock HTTP',
+ headers: {
+ 'content-type': 'application/json; charset=utf-8',
+ 'x-nnd-mock': 'http'
+ },
+ body: JSON.stringify({ mocked: true, transport: 'http', source: 'node-network-devtools' })
+ }
+ },
+ {
+ id: 'manual-fetch-mock',
+ match: {
+ url: `${originBaseUrl}/mock-fetch*`,
+ method: 'POST',
+ headers: { 'x-manual-mock': 'fetch' }
+ },
+ response: {
+ status: 202,
+ statusText: 'Manual Mock Fetch',
+ headers: {
+ 'content-type': 'application/json; charset=utf-8',
+ 'x-nnd-mock': 'fetch'
+ },
+ body: JSON.stringify({ mocked: true, transport: 'fetch', source: 'node-network-devtools' })
+ }
+ }
+]
+
+const originalFunctions = {
+ fetch: globalThis.fetch,
+ httpRequest: http.request,
+ httpsRequest: https.request
+}
+const registration = register({
+ mode: backend === 'legacy' ? 'auto' : 'native',
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false },
+ legacy: {
+ serverPort: 0,
+ ...(backend === 'legacy' ? { mock: mockRules } : {})
+ }
+})
+const ready = await registration.ready
+if (ready.mode !== backend) {
+ throw new Error(`Expected ${backend}, selected ${ready.mode}`)
+}
+const discoveryUrl = new URL(ready.target.discoveryUrl)
+const [discovery, discoveryVersion, discoveryProtocol] = await Promise.all([
+ fetch(discoveryUrl).then((response) => response.json()),
+ fetch(new URL('/json/version', discoveryUrl)).then((response) => response.json()),
+ fetch(new URL('/json/protocol', discoveryUrl)).then((response) => response.json())
+])
+if (!Array.isArray(discovery) || !discovery.some((target) => target.id === ready.target.id)) {
+ throw new Error(`Target ${ready.target.id} is absent from discovery`)
+}
+const networkDomain = discoveryProtocol.domains?.find(
+ (domain) => (domain.name ?? domain.domain) === 'Network'
+)
+if (!networkDomain) throw new Error('/json/protocol does not expose the Network domain')
+const discoveryContract = {
+ list: {
+ ok: true,
+ targetCount: discovery.length,
+ targetIdMatches: discovery.some((target) => target.id === ready.target.id)
+ },
+ version: {
+ ok: true,
+ browser: discoveryVersion.Browser ?? discoveryVersion.browser ?? null,
+ protocolVersion:
+ discoveryVersion['Protocol-Version'] ?? discoveryVersion.protocolVersion ?? null
+ },
+ protocol: {
+ ok: true,
+ domainCount: discoveryProtocol.domains.length,
+ networkDomain: true,
+ networkCommandCount: networkDomain.commands?.length ?? 0,
+ networkEventCount: networkDomain.events?.length ?? 0
+ }
+}
+const recorder = await SessionRecorder.start({
+ directory: sessionDirectory,
+ target: ready.target
+})
+
+const chromiumUserData = await mkdtemp(join(runtimeRoot, `${backend}-chromium-`))
+const chromiumOutput = { stdout: '', stderr: '' }
+const chromiumProcess = spawn(
+ chromium.executablePath(),
+ [
+ '--headless=new',
+ '--remote-debugging-port=0',
+ `--user-data-dir=${chromiumUserData}`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ '--no-sandbox',
+ '--no-proxy-server',
+ '--disable-background-networking',
+ '--disable-component-update',
+ '--disable-default-apps',
+ '--disable-domain-reliability',
+ '--disable-extensions',
+ '--disable-sync',
+ '--metrics-recording-only',
+ '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1'
+ ],
+ { stdio: ['ignore', 'pipe', 'pipe'] }
+)
+chromiumProcess.stdout.setEncoding('utf8')
+chromiumProcess.stderr.setEncoding('utf8')
+chromiumProcess.stdout.on('data', (chunk) => (chromiumOutput.stdout += chunk))
+chromiumProcess.stderr.on('data', (chunk) => (chromiumOutput.stderr += chunk))
+
+const browserWebSocketUrl = await new Promise((resolvePromise, reject) => {
+ const timeout = setTimeout(
+ () => reject(new Error(`Chromium did not expose CDP:\n${chromiumOutput.stderr}`)),
+ 10_000
+ )
+ const onData = () => {
+ const match = chromiumOutput.stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/)
+ if (!match) return
+ clearTimeout(timeout)
+ chromiumProcess.stderr.off('data', onData)
+ resolvePromise(match[1])
+ }
+ chromiumProcess.stderr.on('data', onData)
+ chromiumProcess.once('exit', (code, signal) => {
+ clearTimeout(timeout)
+ reject(new Error(`Chromium exited before CDP (code=${code}, signal=${signal})`))
+ })
+})
+const frontendHost = new URL(browserWebSocketUrl).host
+const targetSocket = ready.target.webSocketDebuggerUrl.replace(/^ws:\/\//, '')
+const frontendUrl =
+ `http://${frontendHost}/devtools/js_app.html?experiments=true&v8only=true` +
+ `&ws=${targetSocket}&hl=en-US`
+
+let scenarioCounter = 0
+let finalized
+let disposed
+const scenarioResults = []
+
+function scenarioToken(scenario) {
+ scenarioCounter += 1
+ return `${backend}-${scenario}-${String(scenarioCounter).padStart(2, '0')}`
+}
+
+async function runScenario(scenario) {
+ const token = scenarioToken(scenario)
+ let result
+ switch (scenario) {
+ case 'http-get':
+ result = await httpRequest(`${originBaseUrl}/get?token=${encodeURIComponent(token)}`)
+ break
+ case 'https-get':
+ result = await httpRequest(
+ `${secureOriginBaseUrl}/secure-get?token=${encodeURIComponent(token)}`
+ )
+ break
+ case 'fetch-post': {
+ const response = await fetch(`${originBaseUrl}/post?token=${encodeURIComponent(token)}`, {
+ method: 'POST',
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'x-manual-case': token },
+ body: `manual-request-body:${token}`
+ })
+ result = {
+ status: response.status,
+ headers: Object.fromEntries(response.headers),
+ body: await response.text()
+ }
+ break
+ }
+ case 'mock-http':
+ if (backend !== 'legacy') throw new Error('Mock is Legacy-only')
+ result = await httpRequest(`${originBaseUrl}/mock-http?token=${encodeURIComponent(token)}`)
+ break
+ case 'mock-fetch': {
+ if (backend !== 'legacy') throw new Error('Mock is Legacy-only')
+ const response = await fetch(
+ `${originBaseUrl}/mock-fetch?token=${encodeURIComponent(token)}`,
+ {
+ method: 'POST',
+ headers: { 'content-type': 'text/plain', 'x-manual-mock': 'fetch' },
+ body: `must-not-reach-origin:${token}`
+ }
+ )
+ result = {
+ status: response.status,
+ headers: Object.fromEntries(response.headers),
+ body: await response.text()
+ }
+ break
+ }
+ case 'trace': {
+ const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
+ const tracestate = 'vendor=manual-pr64'
+ result = await httpRequest(`${originBaseUrl}/trace?token=${encodeURIComponent(token)}`, {
+ headers: { traceparent, tracestate }
+ })
+ break
+ }
+ case 'failed': {
+ let failure
+ try {
+ await httpRequest(`${originBaseUrl}/reset?token=${encodeURIComponent(token)}`)
+ } catch (error) {
+ failure = error
+ }
+ if (!failure) throw new Error('Reset request unexpectedly completed')
+ result = {
+ failed: true,
+ code: failure instanceof Error && 'code' in failure ? failure.code : null,
+ message: failure instanceof Error ? failure.message : String(failure)
+ }
+ break
+ }
+ case 'sse': {
+ const response = await fetch(`${originBaseUrl}/sse?token=${encodeURIComponent(token)}`)
+ result = { status: response.status, body: await response.text() }
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100))
+ break
+ }
+ case 'websocket':
+ result = await (backend === 'native' ? nativeWebSocketRoundTrip : webSocketRoundTrip)(
+ `${originBaseUrl.replace(/^http/, 'ws')}/websocket?token=${encodeURIComponent(token)}`,
+ token
+ )
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100))
+ break
+ default:
+ throw new Error(`Unknown manual scenario: ${scenario}`)
+ }
+
+ const entry = {
+ scenario,
+ token,
+ result,
+ originLeakCount: originRecords.filter((record) => record.pathname.startsWith('/mock-')).length
+ }
+ scenarioResults.push(entry)
+ return entry
+}
+
+async function finalizeSession() {
+ if (finalized) return finalized
+ await recorder.close()
+ const exported = await exportHar(sessionDirectory)
+ const manifest = JSON.parse(await readFile(join(sessionDirectory, 'manifest.json'), 'utf8'))
+ const events = (await readFile(join(sessionDirectory, 'events.ndjson'), 'utf8'))
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .map((line) => JSON.parse(line))
+ const har = exported.har
+ const requestForPath = (pathname) =>
+ events.find((event) => {
+ if (event.method !== 'Network.requestWillBeSent') return false
+ try {
+ return new URL(event.params.request.url).pathname === pathname
+ } catch {
+ return false
+ }
+ })
+ const failedRequest = requestForPath('/reset')
+ const failedRequestId = failedRequest?.params.requestId
+ const failedMethods = failedRequestId
+ ? events
+ .filter((event) => event.params?.requestId === failedRequestId)
+ .map((event) => event.method)
+ : []
+ const webSocketCreated = events.filter((event) => {
+ if (event.method !== 'Network.webSocketCreated') return false
+ try {
+ return new URL(event.params.url).pathname === '/websocket'
+ } catch {
+ return false
+ }
+ })
+ const webSocketRequestIds = new Set(webSocketCreated.map((event) => event.params.requestId))
+ const webSocketMethods = events
+ .filter((event) => webSocketRequestIds.has(event.params?.requestId))
+ .map((event) => event.method)
+ const sseRequest = requestForPath('/sse')
+ const sseRequestId = sseRequest?.params.requestId
+ const sseMessageCount = events.filter(
+ (event) =>
+ event.method === 'Network.eventSourceMessageReceived' &&
+ event.params?.requestId === sseRequestId
+ ).length
+ const explicitTraceRecords = originRecords.filter((record) => record.pathname === '/trace')
+ const untracedRecords = originRecords.filter(
+ (record) => !['/trace', '/websocket'].includes(record.pathname)
+ )
+ const manualAssertions = {
+ discovery: discoveryContract,
+ failedLifecycle: {
+ requestId: failedRequestId ?? null,
+ requestWillBeSent: failedMethods.filter((method) => method === 'Network.requestWillBeSent')
+ .length,
+ responseReceived: failedMethods.filter((method) => method === 'Network.responseReceived')
+ .length,
+ loadingFinished: failedMethods.filter((method) => method === 'Network.loadingFinished')
+ .length,
+ loadingFailed: failedMethods.filter((method) => method === 'Network.loadingFailed').length
+ },
+ webSocketBoundary: {
+ lifecycleCreated: webSocketCreated.length,
+ lifecycleClosed: webSocketMethods.filter((method) => method === 'Network.webSocketClosed')
+ .length,
+ framesSent: webSocketMethods.filter((method) => method === 'Network.webSocketFrameSent')
+ .length,
+ framesReceived: webSocketMethods.filter(
+ (method) => method === 'Network.webSocketFrameReceived'
+ ).length,
+ expectedFrameCapture: backend === 'legacy'
+ },
+ sseBoundary: {
+ requestCaptured: Boolean(sseRequestId),
+ messageEvents: sseMessageCount,
+ expectedMessageCapture: backend === 'legacy'
+ },
+ traceBoundary: {
+ explicitTraceRequests: explicitTraceRecords.length,
+ explicitTracePreserved: explicitTraceRecords.every(
+ (record) =>
+ record.traceparent === '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' &&
+ record.tracestate === 'vendor=manual-pr64'
+ ),
+ untracedBusinessRequests: untracedRecords.length,
+ untracedHeadersAbsent: untracedRecords.every(
+ (record) => record.traceparent === null && record.tracestate === null
+ )
+ }
+ }
+ const assertionsPassed =
+ manualAssertions.failedLifecycle.requestWillBeSent === 1 &&
+ manualAssertions.failedLifecycle.responseReceived === 0 &&
+ manualAssertions.failedLifecycle.loadingFinished === 0 &&
+ manualAssertions.failedLifecycle.loadingFailed === 1 &&
+ manualAssertions.webSocketBoundary.lifecycleCreated === 1 &&
+ manualAssertions.webSocketBoundary.lifecycleClosed === 1 &&
+ (backend === 'legacy'
+ ? manualAssertions.webSocketBoundary.framesSent === 2 &&
+ manualAssertions.webSocketBoundary.framesReceived === 2
+ : manualAssertions.webSocketBoundary.framesSent === 0 &&
+ manualAssertions.webSocketBoundary.framesReceived === 0) &&
+ manualAssertions.sseBoundary.requestCaptured &&
+ (backend === 'legacy'
+ ? manualAssertions.sseBoundary.messageEvents === 2
+ : manualAssertions.sseBoundary.messageEvents === 0) &&
+ manualAssertions.traceBoundary.explicitTraceRequests === 1 &&
+ manualAssertions.traceBoundary.explicitTracePreserved &&
+ manualAssertions.traceBoundary.untracedHeadersAbsent
+ if (!assertionsPassed) {
+ throw new Error(`Manual boundary assertions failed: ${JSON.stringify(manualAssertions)}`)
+ }
+ const replayableHar = {
+ ...har,
+ log: {
+ ...har.log,
+ entries: har.log.entries.filter((entry) => {
+ const url = entry.request?.url
+ const status = entry.response?.status
+ return (
+ typeof url === 'string' &&
+ url.startsWith(`${originBaseUrl}/`) &&
+ Number.isInteger(status) &&
+ status >= 200 &&
+ status < 400
+ )
+ })
+ }
+ }
+ const replayableHarPath = join(runtimeRoot, `${backend}-replayable.har`)
+ await writeFile(replayableHarPath, `${JSON.stringify(replayableHar, null, 2)}\n`, 'utf8')
+ const dryRun = await replay(replayableHarPath, { dryRun: true })
+ const realReplay = await replay(replayableHarPath, { timeoutMs: 5_000 })
+ const traceContexts = Object.values(manifest.traceIndex).flatMap((entry) => entry.spans)
+ finalized = {
+ sessionDirectory: redactText(sessionDirectory),
+ manualAssertions: { passed: true, ...manualAssertions },
+ manifest: {
+ schemaVersion: manifest.schemaVersion,
+ state: manifest.state,
+ stats: manifest.stats,
+ issues: manifest.issues,
+ traceContexts
+ },
+ har: {
+ version: har.log.version,
+ creator: har.log.creator,
+ entries: har.log.entries.length,
+ statuses: har.log.entries.map((entry) => entry.response.status),
+ replayableEntries: replayableHar.log.entries.length
+ },
+ replay: {
+ dryRun: dryRun.dryRun,
+ dryRunRequests: dryRun.results.length,
+ dryRunPassed: dryRun.results.every((entry) => entry.ok),
+ realRequests: realReplay.results.length,
+ realPassed: realReplay.results.every((entry) => entry.ok)
+ },
+ originLeakCount: originRecords.filter((record) => record.pathname.startsWith('/mock-')).length
+ }
+ await Promise.all([
+ writeFile(
+ join(artifactsRoot, `${backend}-session-manifest.json`),
+ redactText(await readFile(join(sessionDirectory, 'manifest.json'), 'utf8')),
+ 'utf8'
+ ),
+ writeFile(
+ join(artifactsRoot, `${backend}-events.ndjson`),
+ redactText(await readFile(join(sessionDirectory, 'events.ndjson'), 'utf8')),
+ 'utf8'
+ ),
+ writeFile(
+ join(artifactsRoot, `${backend}-session.har`),
+ redactText(await readFile(exported.outputPath, 'utf8')),
+ 'utf8'
+ ),
+ writeFile(
+ join(artifactsRoot, `${backend}-replayable.har`),
+ redactText(await readFile(replayableHarPath, 'utf8')),
+ 'utf8'
+ ),
+ writeFile(
+ join(artifactsRoot, `${backend}-finalize-summary.json`),
+ `${JSON.stringify(finalized, null, 2)}\n`,
+ 'utf8'
+ )
+ ])
+ return finalized
+}
+
+async function endpointClosed(url) {
+ try {
+ await fetch(url, { signal: AbortSignal.timeout(750) })
+ return false
+ } catch {
+ return true
+ }
+}
+
+async function disposeTarget() {
+ if (disposed) return disposed
+ if (!finalized) await finalizeSession()
+ await registration.dispose()
+ disposed = {
+ registrationState: registration.status().state,
+ discoveryClosed: await endpointClosed(ready.target.discoveryUrl),
+ targetSocketClosed: await new Promise((resolvePromise) => {
+ const socket = new WebSocket(ready.target.webSocketDebuggerUrl)
+ const timer = setTimeout(() => {
+ socket.terminate()
+ resolvePromise(false)
+ }, 750)
+ socket.once('error', () => {
+ clearTimeout(timer)
+ resolvePromise(true)
+ })
+ socket.once('open', () => {
+ clearTimeout(timer)
+ socket.close()
+ resolvePromise(false)
+ })
+ })
+ }
+ await writeFile(
+ join(artifactsRoot, `${backend}-dispose-summary.json`),
+ `${JSON.stringify(disposed, null, 2)}\n`,
+ 'utf8'
+ )
+ return disposed
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+}
+
+function controlPage(controlUrl) {
+ const capabilities = Object.entries(ready.capabilities)
+ .map(
+ ([name, supported]) =>
+ `${escapeHtml(name)}${supported ? 'YES' : 'NO'}`
+ )
+ .join('')
+ const legacyButtons =
+ backend === 'legacy'
+ ? `
+
+ `
+ : ''
+ return `
+
+
+
+
+ PR #64 manual evidence — ${backend}
+
+
+
+
+ PR #64 · Manual acceptance evidence
+ Node Network Devtools v2 · ${backend.toUpperCase()}
+ Exact packed package, standard CDP discovery, real loopback business traffic.
+
+
+ Runtime identity READY
+
+ - Product commit
- ${escapeHtml(productCommit)}
+ - Package
- ${escapeHtml(packageManifest.name)}@${escapeHtml(packageManifest.version)}
+ - Tarball SHA-256
- ${escapeHtml(tarballSha256)}
+ - Selected backend
- ${escapeHtml(ready.mode)}
+ - Fallback code
- ${escapeHtml(ready.fallbackReason?.code ?? 'none')}
+ - Target id
- ${escapeHtml(ready.target.id)}
+ - Discovery
- ${escapeHtml(ready.target.discoveryUrl)}
+ - WebSocket
- ${escapeHtml(ready.target.webSocketDebuggerUrl)}
+ - API references
- fetch=${globalThis.fetch === originalFunctions.fetch}; http.request=${http.request === originalFunctions.httpRequest}; https.request=${https.request === originalFunctions.httpsRequest}
+ - Discovery contract
- /json/list=${discoveryContract.list.ok}; /json/version=${discoveryContract.version.ok}; /json/protocol.Network=${discoveryContract.protocol.networkDomain}
+
+
+
+ Advertised capabilities
+
+
+
+
+ Manual business scenarios
+
+
+
+
+
+
+
+
+ ${legacyButtons}
+
+
+
+
+ 0 manual scenarios
+ 0 mock origin leaks
+ Session recording
+
+
+
+ Latest observed result
+ Choose a scenario. Each click causes this Node process to issue real outbound traffic.
+
+
+
+
+
+`
+}
+
+const controlSockets = new Set()
+let controlUrl
+const controlServer = http.createServer(async (request, response) => {
+ const url = new URL(request.url, controlUrl ?? 'http://127.0.0.1')
+ try {
+ if (request.method === 'GET' && url.pathname === '/') {
+ const body = controlPage(controlUrl)
+ response.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'content-length': Buffer.byteLength(body),
+ 'cache-control': 'no-store'
+ })
+ response.end(body)
+ return
+ }
+ if (request.method === 'GET' && url.pathname === '/api/status') {
+ jsonResponse(response, 200, { ready, discovery, frontendUrl, controlUrl })
+ return
+ }
+ if (request.method === 'POST' && url.pathname.startsWith('/api/run/')) {
+ jsonResponse(response, 200, await runScenario(url.pathname.slice('/api/run/'.length)))
+ return
+ }
+ if (request.method === 'POST' && url.pathname === '/api/finalize') {
+ jsonResponse(response, 200, await finalizeSession())
+ return
+ }
+ if (request.method === 'POST' && url.pathname === '/api/dispose') {
+ jsonResponse(response, 200, await disposeTarget())
+ return
+ }
+ jsonResponse(response, 404, { error: 'not found' })
+ } catch (error) {
+ jsonResponse(response, 500, {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined
+ })
+ }
+})
+controlServer.on('connection', (socket) => {
+ controlSockets.add(socket)
+ socket.once('close', () => controlSockets.delete(socket))
+})
+const controlPort = await listen(controlServer)
+controlUrl = `http://127.0.0.1:${controlPort}`
+
+const runtimeEvidence = {
+ schemaVersion: 1,
+ productCommit,
+ package: { name: packageManifest.name, version: packageManifest.version },
+ tarballSha256,
+ node: process.version,
+ platform: `${process.platform}-${process.arch}`,
+ backend,
+ selectedMode: ready.mode,
+ fallbackReason: ready.fallbackReason ?? null,
+ capabilities: ready.capabilities,
+ target: ready.target,
+ discovery,
+ discoveryContract,
+ frontendUrl,
+ controlUrl,
+ originalFunctionsPreserved: {
+ fetch: globalThis.fetch === originalFunctions.fetch,
+ httpRequest: http.request === originalFunctions.httpRequest,
+ httpsRequest: https.request === originalFunctions.httpsRequest
+ },
+ sourceSha256: sha256(await readFile(new URL(import.meta.url)))
+}
+const publicRuntimeEvidence = redactEvidence(runtimeEvidence)
+await writeFile(
+ join(artifactsRoot, `${backend}-runtime.json`),
+ `${JSON.stringify(publicRuntimeEvidence, null, 2)}\n`,
+ 'utf8'
+)
+
+process.stdout.write(`NND_MANUAL_READY ${JSON.stringify(publicRuntimeEvidence)}\n`)
+
+let shuttingDown = false
+async function shutdown() {
+ if (shuttingDown) return
+ shuttingDown = true
+ const errors = []
+ if (!finalized) await recorder.close().catch((error) => errors.push(error))
+ await registration.dispose().catch((error) => errors.push(error))
+ for (const socket of webSocketServer.clients) socket.terminate()
+ webSocketServer.close()
+ await closeServer(controlServer, controlSockets).catch((error) => errors.push(error))
+ await closeServer(secureOriginServer, secureOriginSockets).catch((error) => errors.push(error))
+ await closeServer(originServer, originSockets).catch((error) => errors.push(error))
+ if (chromiumProcess.exitCode === null && chromiumProcess.signalCode === null) {
+ chromiumProcess.kill('SIGTERM')
+ }
+ if (errors.length) {
+ process.stderr.write(`${new AggregateError(errors, 'Manual evidence cleanup failed').stack}\n`)
+ process.exitCode = 1
+ }
+}
+
+process.once('SIGINT', () => void shutdown().finally(() => process.exit()))
+process.once('SIGTERM', () => void shutdown().finally(() => process.exit()))
diff --git a/output/playwright/pr-64/manual-localhost-cert.pem b/output/playwright/pr-64/manual-localhost-cert.pem
new file mode 100644
index 0000000..6238242
--- /dev/null
+++ b/output/playwright/pr-64/manual-localhost-cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDGjCCAgKgAwIBAgIUO15hBEI1W9OrbU3Ad56Vt06/geAwDQYJKoZIhvcNAQEL
+BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTI2MDgyODA3MjE0MFoXDTM2MDgy
+NTA3MjE0MFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAymbt8EnBMQrcL3tQDwhaw2OcS29WCDPLw0LoATzXIX+Q
+riZbuqtyRzWuzo9JyUKxFAxbiRoG9YNMwVTxSO7Xm/tDxxXt6G1mAaZAMlL470d6
+L0fKPqtnlUKaIn8QuVLoWOqaaVQ1uxs7J/YcyXZlgVl6kVtdM6ZQxLRG+/U1elnN
+DlllVU9NhFohkp6ScN5oyHfJ0emKKTMkVaX5I/4qNbqg3vGqCoyhqIJ08fDaZOli
+SCrJwNB4Gops59b3raDFeO8D1h4fkxeVgRDMDfWBOd7guIX0FjB54vYSQXQLWf9Y
+MLO3dH1jxRq/+CotnL9eSZHbgvyooZVU666s3SRFbwIDAQABo2QwYjAdBgNVHQ4E
+FgQUQZZhDStW5svzFv4siWHuxE2iiNwwHwYDVR0jBBgwFoAUQZZhDStW5svzFv4s
+iWHuxE2iiNwwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHREECDAGhwR/AAABMA0GCSqG
+SIb3DQEBCwUAA4IBAQBcpVVMVTcB4h6vpJV9WQDtnGuI3dqUOwLscK7MhHUPg3ly
+hvdDkpTt8H3wXHPr6MVsfGkh9AsJtVFOIxmeSGBTSNdY8lHcaFSAFFfd3eCPmBSE
+nYOr7g5A2T1dIzkP5ebYmnT8uFs8bV5F2i7Z4SXX9wCH8Kl0Z6C3XCPKyRV1L+kp
+S977JsbnW5JKXWH9q5MdWpgyTkeX19RtSig2jaJUXYLAsbrX218x90VTBlOtV27W
+4l118RDarilvn1wX/DuRnVGT0vGOEsQk9S3RPtbZsnXrZYonGZDSZMDLVg+mbEe4
+ViNssMye+vdKCqdHWSZCGwrVbZ/ZglK/PN6AU/b8
+-----END CERTIFICATE-----
diff --git a/output/playwright/pr-64/manual-localhost-key.pem b/output/playwright/pr-64/manual-localhost-key.pem
new file mode 100644
index 0000000..29b8a5d
--- /dev/null
+++ b/output/playwright/pr-64/manual-localhost-key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDKZu3wScExCtwv
+e1APCFrDY5xLb1YIM8vDQugBPNchf5CuJlu6q3JHNa7Oj0nJQrEUDFuJGgb1g0zB
+VPFI7teb+0PHFe3obWYBpkAyUvjvR3ovR8o+q2eVQpoifxC5UuhY6pppVDW7Gzsn
+9hzJdmWBWXqRW10zplDEtEb79TV6Wc0OWWVVT02EWiGSnpJw3mjId8nR6YopMyRV
+pfkj/io1uqDe8aoKjKGognTx8Npk6WJIKsnA0Hgaimzn1vetoMV47wPWHh+TF5WB
+EMwN9YE53uC4hfQWMHni9hJBdAtZ/1gws7d0fWPFGr/4Ki2cv15JkduC/KihlVTr
+rqzdJEVvAgMBAAECggEAK30hO+OFDKYSOGuNAKXhZqElCHpUKHAEpKBgHD/3031G
+13xrcjj7VXyd4kkcaA+Z375l/pfmjeMX32SCcZLMJfo/jmvSUgILjGgt3AJC9ZrM
+kJMizANlPsdwOeBGdiNvxAcohWAwKVNJCyWQ+uKjHb9dnBTe+kWOji1UITgHNRHr
+kcFg5rm1+y5swALUj8vJDC6a6a4neJ+iRkm1uM0BR0m0s80c0SeQJSi6bdcJAnvw
+Z4onJLvnnVdYvk2x3/NIMolT2wKhdeM63LYXrBwbxfHEgwW+ljp3UHVBAgXPJ+9i
+WEczCN/erqNlWH01zI3JCUOSAarbbQfpG9Ktq/MkeQKBgQDvSkoKn6z/FFEu6p30
+sI6EC7iivFpwV9/hpPofHz9adAhnCgDSMXWD1KaUYmeGp2ki0PrLEb4bLS9wLCVj
+HEc1mg6x4y7VepylxhYQXP7hv/86h48st2aqnPZEBqsMRPNom6U0iAVN2Otjhncd
+ov+WsQfJW+uynC3xrddXHvGlOQKBgQDYiTQceALiHWG/rliBamM9X7ergFQFx0UV
+AWiPAhZDsYjZQpia3ionjL3TAZ/7YrrRAUfjlGj5hjHDSDKwzGnW4nBly5WpxFTI
+C3ZsD0jaSXphAvSDbrsqPOPPJtqupBEoUZcgpqj6ubBf+3KJaZJyTdYVzWx6Ep1Q
+1IdqkNCn5wKBgQCTBDiTaGE4Yvox8hHUCTm2ZSEuUrzZ8xNpJhxKTL92yn1zGRFC
+EwMZWOhzmDX05BxTOMQtSZxcRBm2OFlMGE6j3yASKPtYS7m8nARoT0qF1plwE2Ni
+3NdmEYO4bcRfiGloJuCMctmXZ6PPhQjgG4oewmt/Skt4dD5CE0WPkEJ0+QKBgF+/
+U5afDo9TdKygeCxJkbZKw9mG9iBT+90M8WIDBztJnnyLMRWR1UwFtM4/8rDi+D6A
+9XvRXRWw3AJAw4Ff8hD9sbuhaP1XfjGyt9uSaqFzSjTpbJtNdF956zXnNV1x2NBe
+O5hJeYDcaPwYII5Ya1Q1zMt5OVVFM+DRCUv62vjTAoGBAI4xYPYed2HI6m0tUAve
+Ebf/nCsN028UauPm8LtvflZgjoEks+PqYQ+KU6TnsIuvxvd9ZQpWUj2erWz7C7J3
+7NcU2+VviYSszd0tkXtwWoTNkFS9/pqqk9R3L4D4zm9n5RPjV7PfQPFK7p38CFj6
+YCXILhmtGDiHVQj04rBVSqJs
+-----END PRIVATE KEY-----
diff --git a/output/playwright/pr-64/manual-native-http2-probe.mjs b/output/playwright/pr-64/manual-native-http2-probe.mjs
new file mode 100644
index 0000000..3838f71
--- /dev/null
+++ b/output/playwright/pr-64/manual-native-http2-probe.mjs
@@ -0,0 +1,84 @@
+import inspector from 'node:inspector'
+import http2 from 'node:http2'
+import { createRequire } from 'node:module'
+import { resolve } from 'node:path'
+
+const consumerRoot = resolve(
+ process.env.NND_MANUAL_CONSUMER_ROOT ?? new URL('./consumer', import.meta.url).pathname
+)
+const manualRequire = createRequire(`${consumerRoot}/consumer.cjs`)
+const { register } = manualRequire('node-network-devtools')
+
+const registration = register({
+ mode: 'native',
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false }
+})
+const ready = await registration.ready
+process.stdout.write(
+ `NND_H2_CAPABILITY ${JSON.stringify({
+ node: process.version,
+ selectedMode: ready.mode,
+ http2: ready.capabilities.http2
+ })}\n`
+)
+
+const cdp = new inspector.Session()
+cdp.connect()
+for (const method of [
+ 'Network.requestWillBeSent',
+ 'Network.responseReceived',
+ 'Network.dataReceived',
+ 'Network.loadingFinished',
+ 'Network.loadingFailed'
+]) {
+ cdp.on(method, ({ params }) => {
+ process.stdout.write(
+ `NND_H2_EVENT ${JSON.stringify({
+ method,
+ requestId: params.requestId,
+ url: params.request?.url ?? params.response?.url,
+ status: params.response?.status,
+ dataLength: params.dataLength,
+ errorText: params.errorText
+ })}\n`
+ )
+ })
+}
+await new Promise((resolvePromise, reject) =>
+ cdp.post('Network.enable', {}, (error) => (error ? reject(error) : resolvePromise()))
+)
+
+const server = http2.createServer()
+server.on('stream', (stream) => {
+ stream.respond({ ':status': 200, 'content-type': 'text/plain', 'x-proof': 'h2' })
+ stream.end('h2-ok')
+})
+await new Promise((resolvePromise, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', resolvePromise)
+})
+const address = server.address()
+if (!address || typeof address === 'string') throw new Error('HTTP/2 server has no port')
+
+const client = http2.connect(`http://127.0.0.1:${address.port}`)
+const request = client.request({
+ ':method': 'GET',
+ ':path': '/native-h2?token=manual-pr64'
+})
+let body = ''
+request.setEncoding('utf8')
+request.on('data', (chunk) => {
+ body += chunk
+})
+request.end()
+await new Promise((resolvePromise, reject) => {
+ request.once('end', resolvePromise)
+ request.once('error', reject)
+})
+process.stdout.write(`NND_H2_PASS ${JSON.stringify({ node: process.version, body })}\n`)
+
+client.close()
+await new Promise((resolvePromise) => server.close(resolvePromise))
+cdp.disconnect()
+await registration.dispose()
diff --git a/output/playwright/pr-64/manual-native-mock-conflict.mjs b/output/playwright/pr-64/manual-native-mock-conflict.mjs
new file mode 100644
index 0000000..ba02896
--- /dev/null
+++ b/output/playwright/pr-64/manual-native-mock-conflict.mjs
@@ -0,0 +1,30 @@
+const packageEntry = new URL(
+ './consumer/node_modules/node-network-devtools/dist/index.mjs',
+ import.meta.url
+)
+const { register } = await import(packageEntry.href)
+
+try {
+ register({
+ mode: 'native',
+ legacy: {
+ mock: [
+ {
+ match: { url: 'http://127.0.0.1/*' },
+ response: { body: 'must-not-register' }
+ }
+ ]
+ }
+ })
+ throw new Error('Native plus Mock unexpectedly registered')
+} catch (error) {
+ const result = {
+ name: error?.name,
+ code: error?.code,
+ message: error?.message,
+ details: error?.details
+ }
+
+ console.log(JSON.stringify(result, null, 2))
+ if (result.code !== 'NND_NATIVE_MOCK_CONFLICT') process.exitCode = 1
+}
diff --git a/output/playwright/pr-64/screenshots/MT-01-cli-version-doctor-native.png b/output/playwright/pr-64/screenshots/MT-01-cli-version-doctor-native.png
new file mode 100644
index 0000000..91b22cb
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-01-cli-version-doctor-native.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-02-cli-zero-code-and-conflict.png b/output/playwright/pr-64/screenshots/MT-02-cli-zero-code-and-conflict.png
new file mode 100644
index 0000000..58b6148
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-02-cli-zero-code-and-conflict.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-03-native-runtime-and-capabilities.png b/output/playwright/pr-64/screenshots/MT-03-native-runtime-and-capabilities.png
new file mode 100644
index 0000000..60c9089
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-03-native-runtime-and-capabilities.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-04-native-version.png b/output/playwright/pr-64/screenshots/MT-04-native-version.png
new file mode 100644
index 0000000..78eea5b
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-04-native-version.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-05-native-devtools-network-list.png b/output/playwright/pr-64/screenshots/MT-05-native-devtools-network-list.png
new file mode 100644
index 0000000..f58a1fb
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-05-native-devtools-network-list.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-05-native-fetch-headers.png b/output/playwright/pr-64/screenshots/MT-05-native-fetch-headers.png
new file mode 100644
index 0000000..e46975b
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-05-native-fetch-headers.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-05-native-fetch-response.png b/output/playwright/pr-64/screenshots/MT-05-native-fetch-response.png
new file mode 100644
index 0000000..4cbbee5
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-05-native-fetch-response.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-06-native-devtools-reload-reconnect.png b/output/playwright/pr-64/screenshots/MT-06-native-devtools-reload-reconnect.png
new file mode 100644
index 0000000..74bc364
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-06-native-devtools-reload-reconnect.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-07-legacy-runtime-and-capabilities.png b/output/playwright/pr-64/screenshots/MT-07-legacy-runtime-and-capabilities.png
new file mode 100644
index 0000000..209eafb
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-07-legacy-runtime-and-capabilities.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-08-legacy-version.png b/output/playwright/pr-64/screenshots/MT-08-legacy-version.png
new file mode 100644
index 0000000..309b690
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-08-legacy-version.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-09-legacy-devtools-network-list.png b/output/playwright/pr-64/screenshots/MT-09-legacy-devtools-network-list.png
new file mode 100644
index 0000000..9800476
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-09-legacy-devtools-network-list.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-09-legacy-fetch-payload.png b/output/playwright/pr-64/screenshots/MT-09-legacy-fetch-payload.png
new file mode 100644
index 0000000..7a38c8d
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-09-legacy-fetch-payload.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-09-legacy-fetch-response.png b/output/playwright/pr-64/screenshots/MT-09-legacy-fetch-response.png
new file mode 100644
index 0000000..1fc8e69
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-09-legacy-fetch-response.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-10-legacy-mock-fetch-response.png b/output/playwright/pr-64/screenshots/MT-10-legacy-mock-fetch-response.png
new file mode 100644
index 0000000..1f24db7
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-10-legacy-mock-fetch-response.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-10-legacy-mock-http-response.png b/output/playwright/pr-64/screenshots/MT-10-legacy-mock-http-response.png
new file mode 100644
index 0000000..034ae8c
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-10-legacy-mock-http-response.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-11-legacy-sse-eventstream.png b/output/playwright/pr-64/screenshots/MT-11-legacy-sse-eventstream.png
new file mode 100644
index 0000000..3af8eb9
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-11-legacy-sse-eventstream.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-11-legacy-websocket-messages.png b/output/playwright/pr-64/screenshots/MT-11-legacy-websocket-messages.png
new file mode 100644
index 0000000..fa80783
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-11-legacy-websocket-messages.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-11-native-websocket-lifecycle.png b/output/playwright/pr-64/screenshots/MT-11-native-websocket-lifecycle.png
new file mode 100644
index 0000000..51e33d8
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-11-native-websocket-lifecycle.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-12-legacy-session-har-replay-trace.png b/output/playwright/pr-64/screenshots/MT-12-legacy-session-har-replay-trace.png
new file mode 100644
index 0000000..f18ee84
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-12-legacy-session-har-replay-trace.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-12-native-session-har-replay-trace.png b/output/playwright/pr-64/screenshots/MT-12-native-session-har-replay-trace.png
new file mode 100644
index 0000000..aca3cc0
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-12-native-session-har-replay-trace.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-13-legacy-boundary-assertions.png b/output/playwright/pr-64/screenshots/MT-13-legacy-boundary-assertions.png
new file mode 100644
index 0000000..5891881
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-13-legacy-boundary-assertions.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-13-legacy-failed-request.png b/output/playwright/pr-64/screenshots/MT-13-legacy-failed-request.png
new file mode 100644
index 0000000..08cf000
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-13-legacy-failed-request.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-13-native-boundary-assertions.png b/output/playwright/pr-64/screenshots/MT-13-native-boundary-assertions.png
new file mode 100644
index 0000000..49313a4
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-13-native-boundary-assertions.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-13-native-failed-request.png b/output/playwright/pr-64/screenshots/MT-13-native-failed-request.png
new file mode 100644
index 0000000..f649ecf
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-13-native-failed-request.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-14-legacy-dispose-cleanup.png b/output/playwright/pr-64/screenshots/MT-14-legacy-dispose-cleanup.png
new file mode 100644
index 0000000..eb11045
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-14-legacy-dispose-cleanup.png differ
diff --git a/output/playwright/pr-64/screenshots/MT-14-native-dispose-cleanup.png b/output/playwright/pr-64/screenshots/MT-14-native-dispose-cleanup.png
new file mode 100644
index 0000000..d2a338f
Binary files /dev/null and b/output/playwright/pr-64/screenshots/MT-14-native-dispose-cleanup.png differ
diff --git a/package.json b/package.json
index 1333ecb..f88d9ab 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,13 @@
"private": true,
"scripts": {
"build": "turbo build",
+ "test": "pnpm --filter node-network-devtools test",
+ "test:e2e:native": "pnpm --filter node-network-devtools test:e2e:native",
+ "test:e2e:legacy": "pnpm --filter node-network-devtools test:e2e:legacy",
+ "test:e2e:enhancements": "pnpm --filter node-network-devtools test:e2e:enhancements",
+ "test:e2e:cli": "pnpm --filter node-network-devtools test:e2e:cli",
+ "test:e2e:frontend": "pnpm --filter node-network-devtools test:e2e:frontend",
+ "quality": "pnpm --filter node-network-devtools test:ci",
"dev": "turbo dev",
"lint": "turbo lint",
"start": "turbo start",
@@ -26,6 +33,6 @@
},
"packageManager": "pnpm@9.12.2",
"engines": {
- "node": ">=18"
+ "node": ">=18.18"
}
}
diff --git a/packages/network-debugger/LICENSE b/packages/network-debugger/LICENSE
new file mode 100644
index 0000000..f6126a2
--- /dev/null
+++ b/packages/network-debugger/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 bugyaluwang
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/network-debugger/README.md b/packages/network-debugger/README.md
index d6c366a..6d6c971 100644
--- a/packages/network-debugger/README.md
+++ b/packages/network-debugger/README.md
@@ -1,82 +1,213 @@
-
-
-
-
Node Network Devtools
-
-
🔮 Use chrome network devtool to debugger nodejs
-
🦎 Similar web crawler experience to browsers
-
⚙️ Powered by CDP
-
-
-
-
-
-
-
-
-
-
----
-
-English | [简体中文](README-zh_CN.md)
-
-## 📖 Introduction
-
-As you can see, the node program opened with the `--inspect` option does not support network tags because it does not proxy user requests.
-Node network devtools is designed to address this issue by allowing you to debug requests made by nodejs using the network tab of Chrome devtools, making the debugging process equivalent to a web crawler experience in the browser.
-
-Node v22.6.0 experimentally supports network debugging. This library supports use before node v22.6.0, but the specific supported versions are unknown.
-
-## 🎮 Features
-
-- [x] HTTP/HTTPS
- - [x] req/res headers
- - [x] payload
- - [x] json str response body
- - [x] binary response body
- - [x] stack follow
- - [x] show stack
- - [x] click to jump
- - [x] base
- - [x] Sourcemap
-- [x] WebSocket
- - [x] messages
- - [x] payload
- - [x] headers
-- [ ] Compatibility
- - [x] commonjs
- - [x] esmodule
- - [ ] Bun
- - [ ] Deno -- Maybe nice to PR to Deno
-- [ ] Undici
- - [ ] undici.request
- - [x] undici.fetch
-
-## 👀 Preview
-
-
-
-## 📦 Quick Start
-
-### 1. Install
+# Node Network Devtools
+
+Inspect outbound Node.js network traffic with the standard Chrome DevTools
+Network panel. Version 2 provides two complete, mutually exclusive backends:
+
+- **Native** connects DevTools directly to Node's experimental Network Inspector.
+- **Legacy** captures outbound APIs in-process and exposes a standard CDP target
+ through an isolated child-process bridge.
+
+The runtime owns a debuggable target, not a Chrome process. Opening DevTools is
+explicit and disposal never kills a browser.
+
+## Requirements
+
+- Node.js `>=18.18`.
+- Native mode requires a Node release with
+ `--experimental-network-inspection`; use `nnd doctor` to inspect the exact
+ runtime capability matrix.
+- Node 18 and 20 are retained as compatibility lanes even though they are EOL.
+- `undici@^6` is a peer dependency so the opt-in Legacy hook observes the same
+ package instance as the application. Package managers that disable automatic
+ peer installation must install it explicitly, even when that hook stays off.
+
+## Install
```bash
-# npm
-npm install node-network-devtools -D
-# or pnpm
-pnpm add node-network-devtools -D
-# or yarn
-yarn add node-network-devtools -D
+npm install --save-dev node-network-devtools
```
-### 2. Usage
+## Zero-code CLI
+
+```bash
+# Start a target and wait for a debugger before running the entry point.
+npx nnd dev src/app.js
-Just add the following code to the entry file of your project.
+# Open the target explicitly.
+npx nnd dev --open src/app.js
-```typescript
+# Start immediately, use tsx, or force a backend.
+npx nnd dev --no-wait --runner tsx --mode legacy src/app.ts
+
+# Machine-readable environment and selection diagnostics.
+npx nnd doctor --json
+```
+
+`nnd dev` supports CJS, ESM, tsx, compiled Nest-style applications, application
+arguments, signals, and watch restarts without a source-code registration edit.
+
+## Library API
+
+```ts
import { register } from 'node-network-devtools'
-process.env.NODE_ENV === 'development' && register()
+const registration = register({
+ mode: 'auto',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false }
+})
+
+const ready = await registration.ready
+console.log(ready.mode, ready.target, ready.capabilities, ready.fallbackReason)
+
+await registration.openDevtools()
+await registration.dispose()
+```
+
+The returned handle remains callable for v1 compatibility:
+
+```ts
+const unregister = register()
+unregister()
+```
+
+Equal repeated registrations return the same handle. A conflicting concurrent
+registration fails with `NND_ALREADY_REGISTERED`.
+
+## Backend capabilities
+
+| Capability | Native | Legacy |
+| ---------------------- | ----------------------- | ------ |
+| HTTP / HTTPS lifecycle | Yes | Yes |
+| Fetch lifecycle | Runtime-dependent | Yes |
+| HTTP/2 | Node 22.20+ (22.x only) | No |
+| Response bodies | Runtime-dependent | Yes |
+| Request bodies | Not advertised | Yes |
+| WebSocket lifecycle | Runtime-dependent | Yes |
+| WebSocket frames | No | Yes |
+| SSE messages | No | Yes |
+| Initiator stack | Yes | Yes |
+| Request/response Mock | No | Yes |
+
+Native capabilities are probed from the running Node version and Inspector API;
+the package does not pretend missing upstream features exist. Forced Native
+fails when requirements cannot be met. Auto prefers a proven Native baseline
+and otherwise exposes a structured Legacy fallback reason.
+
+Node 22 can expose Native HTTP response bodies while returning an empty Fetch
+body. Because `responseBody` covers all advertised transports, the package
+conservatively reports that capability as false on Node 22 and enables it only
+on the verified Node 24+ baseline.
+
+Native HTTP/2 is conservatively allowlisted only for Node 22.20+ releases in
+the 22.x line. A non-empty h2c lifecycle passed on Node 22.22.3, while consuming
+a non-empty response with `setEncoding()` crashes the upstream experimental
+Inspector on Node 24.16.0 and 26.8.1 with `Missing dataLength`. Other and future
+majors remain reported as unsupported until independently verified. Legacy does
+not capture HTTP/2.
+
+Legacy interception can be narrowed:
+
+```ts
+register({
+ mode: 'legacy',
+ legacy: {
+ intercept: { normal: true, fetch: true, undici: { fetch: true } }
+ }
+})
+```
+
+## Session, HAR, and Replay
+
+Record either backend to a portable Session directory and optionally export HAR
+during disposal:
+
+```ts
+const registration = register({
+ session: {
+ directory: '.nnd/sessions/run-001',
+ har: true
+ }
+})
+
+const ready = await registration.ready
+console.log(ready.session)
+await registration.dispose()
+```
+
+The directory contains `manifest.json`, `events.ndjson`, and external files in
+`bodies/`. Existing W3C `traceparent`/`tracestate` headers are correlated in the
+manifest and HAR without injecting or modifying tracing headers.
+
+Session APIs are also exported directly:
+
+```ts
+import { buildHar, exportHar, replay, SessionRecorder } from 'node-network-devtools'
+
+await exportHar('.nnd/sessions/run-001', 'capture.har')
+const plan = await replay('capture.har', { dryRun: true })
+const result = await replay('.nnd/sessions/run-001')
+```
+
+Replay accepts a Session directory, HAR file, or HAR object. It only reissues
+HTTP(S), uses manual redirect handling, and removes hop-by-hop plus runtime-owned
+`Host`/`Content-Length` headers. The CLI exposes the same operation:
+
+```bash
+npx nnd replay --dry-run --json capture.har
+npx nnd replay --stop-on-error .nnd/sessions/run-001
+```
+
+## Legacy-only Mock
+
+```ts
+const registration = register({
+ mode: 'auto',
+ legacy: {
+ mock: [
+ {
+ id: 'fixture',
+ match: {
+ url: 'https://api.example.test/v1/*',
+ method: 'POST',
+ headers: { 'x-test-mode': 'mock' }
+ },
+ response: {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ body: '{"mocked":true}'
+ }
+ }
+ ]
+ }
+})
+```
+
+URL matchers support `*` globs. Use `bodyBase64` for binary responses. Auto
+selects Legacy with diagnostic `NND_AUTO_LEGACY_MOCK_REQUIRED`; forcing Native
+with rules fails explicitly with `NND_NATIVE_MOCK_CONFLICT`. Mocked HTTP, HTTPS,
+global Fetch, and opted-in `undici.fetch` responses still traverse the normal
+Network capture lifecycle.
+
+## Configuration file
+
+`nnd.config.mjs`, `nnd.config.cjs`, and `nnd.config.json` are discovered in the
+working directory. Precedence is CLI > `NND_*` environment > config file >
+defaults.
+
+```js
+export default {
+ mode: 'auto',
+ open: false,
+ wait: true,
+ inspector: { host: '127.0.0.1', port: 0 },
+ requiredCapabilities: ['responseBody'],
+ session: { directory: '.nnd/sessions/local', har: true },
+ legacy: { serverPort: 0 }
+}
```
-
+See the repository's
+[v2 migration guide](https://github.com/GrinZero/node-network-devtools/blob/main/docs/v2-migration.md)
+for old-option mappings and release compatibility details.
diff --git a/packages/network-debugger/package.json b/packages/network-debugger/package.json
index 0e5aeca..17d9181 100644
--- a/packages/network-debugger/package.json
+++ b/packages/network-debugger/package.json
@@ -1,28 +1,54 @@
{
"name": "node-network-devtools",
- "version": "1.0.30",
+ "version": "2.0.0",
"description": "Inspecting Node.js's Network with Chrome DevTools",
"homepage": "https://grinzero.github.io/node-network-devtools/",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
- "types": "./dist/src/index.d.ts",
+ "types": "./dist/index.d.ts",
+ "bin": {
+ "nnd": "./dist/cli.mjs",
+ "node-network-devtools": "./dist/cli.mjs"
+ },
"exports": {
".": {
- "types": "./dist/src/index.d.ts",
+ "types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./dev": {
- "types": "./src/index.ts",
- "import": "./src/index.ts",
- "require": "./src/index.ts"
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ },
+ "./register": {
+ "types": "./dist/preload/register.d.ts",
+ "import": "./dist/register.mjs",
+ "default": "./dist/register.mjs"
+ },
+ "./preload": {
+ "types": "./dist/preload/register.d.ts",
+ "import": "./dist/register.mjs",
+ "default": "./dist/register.mjs"
+ },
+ "./config": {
+ "types": "./dist/config/index.d.ts",
+ "import": "./dist/config.mjs",
+ "require": "./dist/config.js"
}
},
"scripts": {
- "test": "vitest",
+ "test": "vitest run",
+ "test:unit": "vitest run",
+ "test:e2e:native": "node --test test/e2e/native-protocol.test.mjs",
+ "test:e2e:legacy": "node --test test/e2e/legacy/legacy-protocol.test.mjs",
+ "test:e2e:enhancements": "node --test test/e2e/enhancements/enhancements.test.mjs",
+ "test:e2e:cli": "node --test test/e2e/cli/cli.test.mjs",
+ "test:e2e:frontend": "playwright test -c test/e2e/frontend/playwright.config.mjs",
+ "test:ci": "pnpm run test:unit && pnpm run build && pnpm run test:e2e:native && pnpm run test:e2e:legacy && pnpm run test:e2e:enhancements && pnpm run test:e2e:cli && pnpm run test:e2e:frontend",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
- "build": "vite build",
+ "build": "vite build && vite build --config vite.preload.config.ts",
"dev": "vite build --watch --mode development"
},
"keywords": [
@@ -47,7 +73,11 @@
},
"author": "bugyaluwang (https://github.com/GrinZero)",
"license": "MIT",
+ "engines": {
+ "node": ">=18.18"
+ },
"devDependencies": {
+ "@playwright/test": "1.62.1",
"@types/node": "^20.11.0",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.14.0",
@@ -57,6 +87,7 @@
"fast-check": "^4.5.3",
"memfs": "^4.56.10",
"tslib": "2.6.2",
+ "tsx": "4.23.12",
"vite": "^5.2.10",
"vite-plugin-dts": "^3.8.3",
"vitest": "^4.0.18"
@@ -64,7 +95,6 @@
"dependencies": {
"bufferutil": "^4.0.9",
"iconv-lite": "^0.7.0",
- "inspector": "^0.5.0",
"open": "^8.4.2",
"ws": "^8.17.1"
},
diff --git a/packages/network-debugger/src/adapters/legacy/index.test.ts b/packages/network-debugger/src/adapters/legacy/index.test.ts
new file mode 100644
index 0000000..41c8db8
--- /dev/null
+++ b/packages/network-debugger/src/adapters/legacy/index.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from 'vitest'
+import { LEGACY_CAPABILITIES, LegacyAdapter } from '.'
+
+describe('LegacyAdapter capabilities', () => {
+ test('advertises the tested default Legacy feature set', () => {
+ expect(new LegacyAdapter().probe().capabilities).toEqual(LEGACY_CAPABILITIES)
+ })
+
+ test('does not advertise transports explicitly disabled by interception config', () => {
+ expect(
+ new LegacyAdapter({ intercept: { normal: false, fetch: true } }).probe().capabilities
+ ).toMatchObject({
+ http: false,
+ https: false,
+ fetch: true,
+ websocketLifecycle: false,
+ websocketFrames: false,
+ sseMessages: true,
+ responseBody: true
+ })
+
+ expect(
+ new LegacyAdapter({ intercept: { normal: false, fetch: false } }).probe().capabilities
+ ).toMatchObject({
+ http: false,
+ https: false,
+ fetch: false,
+ responseBody: false,
+ requestBody: false,
+ sseMessages: false,
+ initiator: false
+ })
+ })
+})
diff --git a/packages/network-debugger/src/adapters/legacy/index.ts b/packages/network-debugger/src/adapters/legacy/index.ts
new file mode 100644
index 0000000..c2802a3
--- /dev/null
+++ b/packages/network-debugger/src/adapters/legacy/index.ts
@@ -0,0 +1,195 @@
+import http from 'http'
+import https from 'https'
+import { syncBuiltinESMExports } from 'node:module'
+import { type InterceptOptions } from '../../common'
+import { MainProcess } from '../../core/fork'
+import { proxyFetch } from '../../core/fetch'
+import { getProxyFactory, requestProxyFactory, type RequestFn } from '../../core/request'
+import { undiciFetchProxy } from '../../core/undici'
+import { mockableRequestHandler, type LegacyMockRule } from '../../mock'
+import { generateHash } from '../../utils'
+import type {
+ AdapterProbe,
+ AdapterSession,
+ AdapterStartOptions,
+ CapabilityMap,
+ DebugAdapter,
+ Diagnostic
+} from '../types'
+
+export const LEGACY_CAPABILITIES: CapabilityMap = Object.freeze({
+ http: true,
+ https: true,
+ fetch: true,
+ http2: false,
+ responseBody: true,
+ requestBody: true,
+ websocketLifecycle: true,
+ websocketFrames: true,
+ sseMessages: true,
+ initiator: true
+})
+
+export interface LegacyAdapterOptions {
+ port?: number
+ serverPort?: number
+ autoOpenDevtool?: boolean
+ intercept?: InterceptOptions
+ mock?: readonly LegacyMockRule[]
+ diagnostics?: readonly Diagnostic[]
+}
+
+function capabilitiesFor(intercept: InterceptOptions = {}): CapabilityMap {
+ const normal = intercept.normal !== false
+ const fetch = intercept.fetch !== false || Boolean(intercept.undici && intercept.undici.fetch)
+ const anyRequest = normal || fetch
+ return Object.freeze({
+ http: normal,
+ https: normal,
+ fetch,
+ http2: false,
+ responseBody: anyRequest,
+ requestBody: anyRequest,
+ websocketLifecycle: normal,
+ websocketFrames: normal,
+ sseMessages: fetch,
+ initiator: anyRequest
+ })
+}
+
+/**
+ * Compatibility wrapper around the original capture path.
+ *
+ * All application capture patches live here, while a child-process IPC bridge
+ * owns the project CDP target. Native sessions therefore cannot accidentally
+ * activate any Legacy interception.
+ */
+export class LegacyAdapter implements DebugAdapter {
+ readonly kind = 'legacy' as const
+
+ constructor(private readonly options: LegacyAdapterOptions = {}) {}
+
+ probe(_options?: AdapterStartOptions): AdapterProbe {
+ return {
+ kind: this.kind,
+ available: true,
+ autoSelectable: true,
+ capabilities: capabilitiesFor(this.options.intercept),
+ diagnostics: []
+ }
+ }
+
+ async start(_options?: AdapterStartOptions): Promise {
+ const port = this.options.port
+ const serverPort = this.options.serverPort ?? 0
+ const autoOpenDevtool = this.options.autoOpenDevtool ?? false
+ const intercept = this.options.intercept ?? {}
+ const mockRules = this.options.mock ?? []
+ const {
+ fetch: interceptFetch = true,
+ normal: interceptNormal = true,
+ undici: interceptUndici = false
+ } = intercept
+ const interceptUndiciFetch = Boolean(interceptUndici && interceptUndici.fetch)
+ const capabilities = capabilitiesFor(intercept)
+ const key = generateHash(JSON.stringify({ port, serverPort, autoOpenDevtool }))
+ const mainProcess = new MainProcess({ port, serverPort, autoOpenDevtool, key })
+
+ const unsetFetchProxy = interceptFetch
+ ? mockRules.length > 0
+ ? proxyFetch(mainProcess, mockRules)
+ : proxyFetch(mainProcess)
+ : undefined
+ const originalRequests = new Map<
+ typeof http | typeof https,
+ { request: RequestFn; get: RequestFn; proxyRequest: RequestFn; proxyGet: RequestFn }
+ >()
+ const agents = [http, https] as const
+
+ if (interceptNormal) {
+ for (const agent of agents) {
+ const request = agent.request as RequestFn
+ const get = agent.get as RequestFn
+ const requestHandler =
+ mockRules.length > 0
+ ? mockableRequestHandler(request, agent === https, mockRules)
+ : request
+ const proxyRequest = requestProxyFactory.call(
+ agent,
+ requestHandler,
+ agent === https,
+ mainProcess
+ )
+ const proxyGet = getProxyFactory(proxyRequest)
+ originalRequests.set(agent, { request, get, proxyRequest, proxyGet })
+ agent.request = proxyRequest as typeof agent.request
+ agent.get = proxyGet as typeof agent.get
+ }
+ syncBuiltinESMExports()
+ }
+
+ const unsetUndiciFetch = interceptUndiciFetch
+ ? mockRules.length > 0
+ ? undiciFetchProxy(mainProcess, mockRules)
+ : undiciFetchProxy(mainProcess)
+ : undefined
+ let disposed = false
+
+ const restoreCapture = () => {
+ unsetFetchProxy?.()
+ if (interceptNormal) {
+ for (const agent of agents) {
+ const original = originalRequests.get(agent)
+ if (!original) continue
+ if (agent.request === original.proxyRequest) {
+ agent.request = original.request as typeof agent.request
+ }
+ if (agent.get === original.proxyGet) {
+ agent.get = original.get as typeof agent.get
+ }
+ }
+ originalRequests.clear()
+ syncBuiltinESMExports()
+ }
+ unsetUndiciFetch?.()
+ }
+
+ let target
+ try {
+ target = await mainProcess.ready
+ } catch (error) {
+ restoreCapture()
+ await mainProcess.dispose().catch(() => {})
+ throw error
+ }
+
+ const diagnostics: Diagnostic[] = [...(this.options.diagnostics ?? [])]
+ if (port !== undefined) {
+ diagnostics.push({
+ code: 'NND_LEGACY_BRIDGE_PORT_DEPRECATED',
+ level: 'warn',
+ message: 'Legacy bridge port is no longer used because capture now uses child-process IPC.',
+ hint: 'Remove legacy.port (or the deprecated top-level port option). Use legacy.serverPort only to pin the CDP target port.'
+ })
+ }
+
+ return {
+ kind: this.kind,
+ capabilities,
+ diagnostics,
+ target,
+ onDiagnostic(listener: (diagnostic: Diagnostic) => void) {
+ return mainProcess.onDiagnostic(listener)
+ },
+ onFailure(listener: (error: Error) => void) {
+ return mainProcess.onFailure(listener)
+ },
+ async dispose() {
+ if (disposed) return
+ disposed = true
+ restoreCapture()
+ await mainProcess.dispose()
+ }
+ }
+ }
+}
diff --git a/packages/network-debugger/src/adapters/node-native/capability.test.ts b/packages/network-debugger/src/adapters/node-native/capability.test.ts
new file mode 100644
index 0000000..28cb693
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/capability.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, test, vi } from 'vitest'
+import {
+ NATIVE_CAPABILITIES,
+ getMissingCapabilities,
+ getNativeCapabilities,
+ hasNativeInspectionFlag,
+ isNativeAutoBaseline,
+ parseNodeVersion,
+ supportsNativeNetworkInspection,
+ type NativeNetworkApi
+} from './capability'
+
+const lifecycleNetwork = (): NativeNetworkApi => ({
+ requestWillBeSent: vi.fn(),
+ responseReceived: vi.fn(),
+ loadingFinished: vi.fn(),
+ loadingFailed: vi.fn()
+})
+
+describe('native capability detection', () => {
+ test('publishes a conservative maximum capability set', () => {
+ expect(NATIVE_CAPABILITIES).toMatchObject({
+ http: true,
+ https: true,
+ fetch: true,
+ http2: true,
+ responseBody: true,
+ requestBody: false,
+ websocketLifecycle: true,
+ websocketFrames: false,
+ sseMessages: false,
+ initiator: true
+ })
+ })
+
+ test.each([
+ ['20.17.0', false],
+ ['20.18.0', true],
+ ['21.7.3', false],
+ ['22.5.0', false],
+ ['22.6.0', true],
+ ['23.0.0', true],
+ ['24.0.0', true]
+ ])('detects native inspection runtime support for Node %s', (text, expected) => {
+ expect(supportsNativeNetworkInspection(parseNodeVersion(text))).toBe(expected)
+ })
+
+ test.each([
+ ['24.6.0', false],
+ ['24.7.0', true],
+ ['25.0.0', true],
+ ['22.22.0', false]
+ ])('uses a stricter Auto baseline for Node %s', (text, expected) => {
+ expect(isNativeAutoBaseline(parseNodeVersion(text))).toBe(expected)
+ })
+
+ test('rejects malformed versions', () => {
+ expect(parseNodeVersion('nightly')).toBeNull()
+ })
+
+ test('detects the experimental flag without accepting lookalikes', () => {
+ expect(hasNativeInspectionFlag(['--experimental-network-inspection'])).toBe(true)
+ expect(hasNativeInspectionFlag(['--experimental-network-inspection=true'])).toBe(true)
+ expect(hasNativeInspectionFlag(['--experimental-network-inspection-extra'])).toBe(false)
+ })
+
+ test('derives optional capabilities from runtime methods and versions', () => {
+ const network: NativeNetworkApi = {
+ ...lifecycleNetwork(),
+ dataReceived: vi.fn(),
+ dataSent: vi.fn(),
+ webSocketCreated: vi.fn(),
+ webSocketHandshakeResponseReceived: vi.fn(),
+ webSocketClosed: vi.fn()
+ }
+ const capabilities = getNativeCapabilities(parseNodeVersion('24.8.0'), network)
+
+ expect(capabilities).toMatchObject({
+ http: true,
+ https: true,
+ fetch: true,
+ http2: false,
+ responseBody: true,
+ requestBody: false,
+ websocketLifecycle: true,
+ websocketFrames: false,
+ sseMessages: false,
+ initiator: true
+ })
+ })
+
+ test.each([
+ ['22.19.0', false],
+ ['22.20.0', true],
+ ['22.22.3', true],
+ ['23.11.1', false],
+ ['24.16.0', false],
+ ['25.7.0', false],
+ ['26.8.1', false],
+ ['99.0.0', false]
+ ])('uses the explicit Native HTTP/2 allowlist for Node %s', (text, expected) => {
+ const capabilities = getNativeCapabilities(parseNodeVersion(text), lifecycleNetwork())
+ expect(capabilities.http2).toBe(expected)
+ })
+
+ test('does not infer HTTP request-body support from dataSent', () => {
+ const capabilities = getNativeCapabilities(parseNodeVersion('26.1.0'), {
+ ...lifecycleNetwork(),
+ dataSent: vi.fn()
+ })
+ expect(capabilities.requestBody).toBe(false)
+ })
+
+ test('does not advertise cross-transport response bodies on Node 22', () => {
+ const capabilities = getNativeCapabilities(parseNodeVersion('22.22.3'), {
+ ...lifecycleNetwork(),
+ dataReceived: vi.fn()
+ })
+ expect(capabilities).toMatchObject({ fetch: true, responseBody: false })
+ })
+
+ test('turns protocol capabilities off when lifecycle methods are incomplete', () => {
+ const capabilities = getNativeCapabilities(parseNodeVersion('26.1.0'), {
+ requestWillBeSent: vi.fn()
+ })
+ expect(capabilities.http).toBe(false)
+ expect(capabilities.fetch).toBe(false)
+ expect(capabilities.initiator).toBe(false)
+ })
+
+ test('reports required capabilities that are unavailable', () => {
+ const capabilities = getNativeCapabilities(parseNodeVersion('24.7.0'), lifecycleNetwork())
+ expect(
+ getMissingCapabilities(capabilities, ['http', 'responseBody', 'websocketFrames'])
+ ).toEqual(['responseBody', 'websocketFrames'])
+ })
+})
diff --git a/packages/network-debugger/src/adapters/node-native/capability.ts b/packages/network-debugger/src/adapters/node-native/capability.ts
new file mode 100644
index 0000000..bd94bfa
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/capability.ts
@@ -0,0 +1,161 @@
+import type { CapabilityMap, NetworkCapability } from '../types'
+
+export const NATIVE_NETWORK_INSPECTION_FLAG = '--experimental-network-inspection'
+
+/**
+ * The maximum capability set currently exposed by Node's native inspector.
+ * Runtime probing may turn individual capabilities off for older Node releases.
+ *
+ * `requestBody` is intentionally false: Node's native HTTP/1 inspector does not
+ * yet provide request bodies consistently, so the adapter must not advertise a
+ * capability which only works for some transports.
+ */
+export const NATIVE_CAPABILITIES: CapabilityMap = Object.freeze({
+ http: true,
+ https: true,
+ fetch: true,
+ http2: true,
+ responseBody: true,
+ requestBody: false,
+ websocketLifecycle: true,
+ websocketFrames: false,
+ sseMessages: false,
+ initiator: true
+})
+
+export const REQUIRED_NATIVE_NETWORK_METHODS = [
+ 'requestWillBeSent',
+ 'responseReceived',
+ 'loadingFinished',
+ 'loadingFailed'
+] as const
+
+export const OPTIONAL_NATIVE_NETWORK_METHODS = [
+ 'dataReceived',
+ 'dataSent',
+ 'webSocketCreated',
+ 'webSocketHandshakeResponseReceived',
+ 'webSocketClosed'
+] as const
+
+export type NativeNetworkMethod =
+ | (typeof REQUIRED_NATIVE_NETWORK_METHODS)[number]
+ | (typeof OPTIONAL_NATIVE_NETWORK_METHODS)[number]
+
+export type NativeNetworkApi = Partial unknown>>
+
+export interface NodeVersion {
+ major: number
+ minor: number
+ patch: number
+}
+
+export function parseNodeVersion(version: string): NodeVersion | null {
+ const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)/.exec(version.trim())
+ if (!match) return null
+
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3])
+ }
+}
+
+function atLeast(version: NodeVersion, major: number, minor: number, patch = 0) {
+ if (version.major !== major) return version.major > major
+ if (version.minor !== minor) return version.minor > minor
+ return version.patch >= patch
+}
+
+/** The flag first shipped in Node 20.18 and Node 22.6. */
+export function supportsNativeNetworkInspection(version: NodeVersion | null) {
+ if (!version) return false
+ if (version.major === 20) return atLeast(version, 20, 18)
+ if (version.major === 21) return false
+ if (version.major === 22) return atLeast(version, 22, 6)
+ return version.major >= 23
+}
+
+/**
+ * Auto selection deliberately has a stricter, proven baseline than explicit
+ * Native selection. Older supported releases remain available when requested
+ * explicitly, but Auto should prefer Legacy there.
+ */
+export function isNativeAutoBaseline(version: NodeVersion | null) {
+ if (!version) return false
+ if (version.major > 24) return true
+ return version.major === 24 && atLeast(version, 24, 7)
+}
+
+function supportsNativeFetch(version: NodeVersion | null) {
+ if (!version) return false
+ if (version.major === 22) return atLeast(version, 22, 14)
+ if (version.major === 23) return atLeast(version, 23, 7)
+ return version.major >= 24
+}
+
+function supportsNativeHttp2(version: NodeVersion | null) {
+ if (!version) return false
+
+ // Keep this as an explicit allowlist. A non-empty h2c lifecycle is verified on
+ // Node 22.20+, while newer majors have regressed in the experimental Inspector.
+ // Future majors must be verified independently instead of inheriting support.
+ return version.major === 22 && atLeast(version, 22, 20)
+}
+
+/**
+ * Node 22 exposes dataReceived but its Fetch getResponseBody result can be
+ * empty. The public capability spans every advertised transport, so only the
+ * verified Node 24+ baseline may claim complete response-body support.
+ */
+function supportsCompleteNativeResponseBodies(version: NodeVersion | null) {
+ return version !== null && version.major >= 24
+}
+
+export function hasRequiredNativeMethods(network: NativeNetworkApi | undefined) {
+ return REQUIRED_NATIVE_NETWORK_METHODS.every((method) => typeof network?.[method] === 'function')
+}
+
+export function getNativeCapabilities(
+ version: NodeVersion | null,
+ network: NativeNetworkApi | undefined
+): CapabilityMap {
+ const lifecycle = hasRequiredNativeMethods(network)
+ const websocketLifecycle =
+ lifecycle &&
+ typeof network?.webSocketCreated === 'function' &&
+ typeof network.webSocketHandshakeResponseReceived === 'function' &&
+ typeof network.webSocketClosed === 'function'
+
+ return Object.freeze({
+ http: lifecycle,
+ https: lifecycle,
+ fetch: lifecycle && supportsNativeFetch(version),
+ http2: lifecycle && supportsNativeHttp2(version),
+ responseBody:
+ lifecycle &&
+ supportsCompleteNativeResponseBodies(version) &&
+ typeof network?.dataReceived === 'function',
+ // Deliberately not inferred from dataSent; HTTP/1 request bodies remain incomplete.
+ requestBody: false,
+ websocketLifecycle,
+ websocketFrames: false,
+ sseMessages: false,
+ initiator: lifecycle
+ })
+}
+
+export function getMissingCapabilities(
+ capabilities: CapabilityMap,
+ required: readonly NetworkCapability[] = []
+) {
+ return required.filter((capability) => capabilities[capability] !== true)
+}
+
+export function hasNativeInspectionFlag(execArgv: readonly string[]) {
+ return execArgv.some(
+ (argument) =>
+ argument === NATIVE_NETWORK_INSPECTION_FLAG ||
+ argument.startsWith(`${NATIVE_NETWORK_INSPECTION_FLAG}=`)
+ )
+}
diff --git a/packages/network-debugger/src/adapters/node-native/errors.ts b/packages/network-debugger/src/adapters/node-native/errors.ts
new file mode 100644
index 0000000..5a2d7f1
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/errors.ts
@@ -0,0 +1,31 @@
+import type { Diagnostic } from '../types'
+
+export class NodeNativeAdapterError extends Error {
+ readonly code: string
+ readonly hint?: string
+ readonly diagnostics: readonly Diagnostic[]
+
+ constructor(diagnostic: Diagnostic, diagnostics: readonly Diagnostic[] = [diagnostic]) {
+ super(diagnostic.message)
+ this.name = 'NodeNativeAdapterError'
+ this.code = diagnostic.code
+ this.hint = diagnostic.hint
+ this.diagnostics = diagnostics
+ }
+}
+
+export function nativeDiagnostic(
+ code: string,
+ message: string,
+ hint?: string,
+ details?: Readonly>,
+ level: Diagnostic['level'] = 'error'
+): Diagnostic {
+ return {
+ code,
+ level,
+ message,
+ ...(hint ? { hint } : {}),
+ ...(details ? { details } : {})
+ }
+}
diff --git a/packages/network-debugger/src/adapters/node-native/index.test.ts b/packages/network-debugger/src/adapters/node-native/index.test.ts
new file mode 100644
index 0000000..6d2639e
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/index.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, test, vi } from 'vitest'
+import type { NativeNetworkApi } from './capability'
+import { NodeNativeAdapter, NodeNativeAdapterError, type NativeInspectorApi } from './index'
+
+const inspectorUrl = 'ws://127.0.0.1:9229/target-id'
+
+function createNetwork(overrides: NativeNetworkApi = {}): NativeNetworkApi {
+ return {
+ requestWillBeSent: vi.fn(),
+ responseReceived: vi.fn(),
+ loadingFinished: vi.fn(),
+ loadingFailed: vi.fn(),
+ dataReceived: vi.fn(),
+ dataSent: vi.fn(),
+ webSocketCreated: vi.fn(),
+ webSocketHandshakeResponseReceived: vi.fn(),
+ webSocketClosed: vi.fn(),
+ ...overrides
+ }
+}
+
+function descriptor(url = inspectorUrl) {
+ return [
+ {
+ id: 'target-id',
+ title: 'node[123]',
+ type: 'node',
+ url: 'file:///app.js',
+ webSocketDebuggerUrl: url,
+ devtoolsFrontendUrl: 'devtools://native-target'
+ }
+ ]
+}
+
+function createInspector(initialUrl: string | null = inspectorUrl) {
+ let currentUrl = initialUrl ?? undefined
+ const inspector: NativeInspectorApi = {
+ url: vi.fn(() => currentUrl),
+ open: vi.fn(() => {
+ currentUrl = inspectorUrl
+ }),
+ close: vi.fn(),
+ Network: createNetwork()
+ }
+ return inspector
+}
+
+function createAdapter(
+ inspector: NativeInspectorApi | null,
+ overrides: ConstructorParameters[0] = {}
+) {
+ return new NodeNativeAdapter({
+ inspector,
+ inspectorAvailable: inspector !== null,
+ execArgv: ['--experimental-network-inspection'],
+ nodeVersion: '24.8.0',
+ requestJson: vi.fn().mockResolvedValue(descriptor()),
+ ...overrides
+ })
+}
+
+describe('NodeNativeAdapter probe', () => {
+ test('reports a supported runtime as available and Auto-selectable', () => {
+ const probe = createAdapter(createInspector()).probe()
+ expect(probe.available).toBe(true)
+ expect(probe.autoSelectable).toBe(true)
+ expect(probe.diagnostics).toEqual([])
+ })
+
+ test('reports missing Inspector support with a stable code', () => {
+ const probe = createAdapter(null).probe()
+ expect(probe.available).toBe(false)
+ expect(probe.diagnostics).toContainEqual(
+ expect.objectContaining({ code: 'NND_NATIVE_INSPECTOR_UNAVAILABLE' })
+ )
+ })
+
+ test('reports an unsupported runtime', () => {
+ const probe = createAdapter(createInspector(), { nodeVersion: '18.20.0' }).probe()
+ expect(probe.available).toBe(false)
+ expect(probe.diagnostics).toContainEqual(
+ expect.objectContaining({ code: 'NND_NATIVE_RUNTIME_UNSUPPORTED' })
+ )
+ })
+
+ test('requires the experimental flag', () => {
+ const probe = createAdapter(createInspector(), { execArgv: [] }).probe()
+ expect(probe.available).toBe(false)
+ expect(probe.diagnostics).toContainEqual(
+ expect.objectContaining({
+ code: 'NND_NATIVE_FLAG_REQUIRED',
+ hint: expect.stringContaining('--experimental-network-inspection')
+ })
+ )
+ })
+
+ test('requires the native lifecycle methods', () => {
+ const inspector = createInspector()
+ inspector.Network = { requestWillBeSent: vi.fn() }
+ const probe = createAdapter(inspector).probe()
+ expect(probe.available).toBe(false)
+ expect(probe.diagnostics).toContainEqual(
+ expect.objectContaining({ code: 'NND_NATIVE_METHODS_UNAVAILABLE' })
+ )
+ })
+
+ test('validates required capabilities', () => {
+ const probe = createAdapter(createInspector()).probe({
+ requiredCapabilities: ['websocketFrames', 'requestBody']
+ })
+ expect(probe.available).toBe(false)
+ expect(probe.diagnostics).toContainEqual(
+ expect.objectContaining({
+ code: 'NND_NATIVE_REQUIRED_CAPABILITY_UNAVAILABLE',
+ details: { missingCapabilities: ['websocketFrames', 'requestBody'] }
+ })
+ )
+ })
+
+ test('keeps older supported runtimes explicit-only', () => {
+ const probe = createAdapter(createInspector(), { nodeVersion: '22.20.0' }).probe()
+ expect(probe.available).toBe(true)
+ expect(probe.autoSelectable).toBe(false)
+ expect(probe.diagnostics).toContainEqual(
+ expect.objectContaining({
+ code: 'NND_NATIVE_AUTO_BASELINE_UNPROVEN',
+ level: 'warn'
+ })
+ )
+ })
+})
+
+describe('NodeNativeAdapter start and ownership', () => {
+ test('forced start exposes the first actionable probe error', async () => {
+ const adapter = createAdapter(createInspector(), { execArgv: [] })
+ await expect(adapter.start()).rejects.toMatchObject({
+ code: 'NND_NATIVE_FLAG_REQUIRED',
+ hint: expect.stringContaining('--experimental-network-inspection')
+ })
+ })
+
+ test('reuses an existing target without opening or closing it', async () => {
+ const inspector = createInspector()
+ const requestJson = vi.fn().mockResolvedValue(descriptor())
+ const session = await createAdapter(inspector, { requestJson }).start()
+
+ expect(inspector.open).not.toHaveBeenCalled()
+ expect(session.target.webSocketDebuggerUrl).toBe(inspectorUrl)
+ expect(requestJson).toHaveBeenCalledWith('http://127.0.0.1:9229/json/list', 500)
+
+ await session.dispose()
+ expect(inspector.close).not.toHaveBeenCalled()
+ })
+
+ test('opens an OS-assigned target and disposes only its owned handle', async () => {
+ const inspector = createInspector(null)
+ const disposeSymbol = (Symbol as unknown as { dispose?: symbol }).dispose ?? Symbol('dispose')
+ const dispose = vi.fn()
+ ;(inspector.open as ReturnType).mockImplementation(() => {
+ ;(inspector.url as ReturnType).mockReturnValue(inspectorUrl)
+ return { [disposeSymbol]: dispose }
+ })
+
+ const originalSymbolDispose = (Symbol as unknown as { dispose?: symbol }).dispose
+ if (!originalSymbolDispose) {
+ Object.defineProperty(Symbol, 'dispose', { value: disposeSymbol, configurable: true })
+ }
+
+ try {
+ const session = await createAdapter(inspector).start({
+ inspector: { host: 'localhost', port: 0 }
+ })
+ expect(inspector.open).toHaveBeenCalledWith(0, 'localhost', false)
+
+ await session.dispose()
+ await session.dispose()
+ expect(dispose).toHaveBeenCalledTimes(1)
+ expect(inspector.close).not.toHaveBeenCalled()
+ } finally {
+ if (!originalSymbolDispose) {
+ delete (Symbol as unknown as { dispose?: symbol }).dispose
+ }
+ }
+ })
+
+ test('closes an owned Inspector when target discovery fails', async () => {
+ const inspector = createInspector(null)
+ const adapter = createAdapter(inspector, {
+ requestJson: vi.fn().mockRejectedValue(new Error('connection refused')),
+ discovery: { attempts: 1 }
+ })
+
+ await expect(adapter.start()).rejects.toMatchObject({
+ code: 'NND_NATIVE_TARGET_DISCOVERY_FAILED'
+ })
+ expect(inspector.close).toHaveBeenCalledTimes(1)
+ })
+
+ test('does not close a reused Inspector when target discovery fails', async () => {
+ const inspector = createInspector()
+ const adapter = createAdapter(inspector, {
+ requestJson: vi.fn().mockRejectedValue(new Error('connection refused')),
+ discovery: { attempts: 1 }
+ })
+
+ await expect(adapter.start()).rejects.toMatchObject({
+ code: 'NND_NATIVE_TARGET_DISCOVERY_FAILED'
+ })
+ expect(inspector.open).not.toHaveBeenCalled()
+ expect(inspector.close).not.toHaveBeenCalled()
+ })
+
+ test('closes an owned Inspector that does not publish a URL', async () => {
+ const inspector = createInspector(null)
+ ;(inspector.open as ReturnType).mockImplementation(() => undefined)
+
+ await expect(createAdapter(inspector).start()).rejects.toMatchObject({
+ code: 'NND_NATIVE_TARGET_URL_UNAVAILABLE'
+ })
+ expect(inspector.close).toHaveBeenCalledTimes(1)
+ })
+
+ test('reports Inspector open failures with a stable code', async () => {
+ const inspector = createInspector(null)
+ ;(inspector.open as ReturnType).mockImplementation(() => {
+ throw new Error('address in use')
+ })
+
+ await expect(createAdapter(inspector).start()).rejects.toMatchObject({
+ code: 'NND_NATIVE_TARGET_OPEN_FAILED',
+ hint: expect.stringContaining('--inspect=0')
+ })
+ })
+})
diff --git a/packages/network-debugger/src/adapters/node-native/index.ts b/packages/network-debugger/src/adapters/node-native/index.ts
new file mode 100644
index 0000000..71b9967
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/index.ts
@@ -0,0 +1,251 @@
+import * as nodeInspector from 'node:inspector'
+import type {
+ AdapterProbe,
+ AdapterSession,
+ AdapterStartOptions,
+ DebugAdapter,
+ Diagnostic
+} from '../types'
+import {
+ NATIVE_CAPABILITIES,
+ NATIVE_NETWORK_INSPECTION_FLAG,
+ type NativeNetworkApi,
+ getMissingCapabilities,
+ getNativeCapabilities,
+ hasNativeInspectionFlag,
+ hasRequiredNativeMethods,
+ isNativeAutoBaseline,
+ parseNodeVersion,
+ supportsNativeNetworkInspection
+} from './capability'
+import { NodeNativeAdapterError, nativeDiagnostic } from './errors'
+import {
+ discoverInspectorTarget,
+ type TargetDiscoveryDependencies,
+ type TargetDiscoveryOptions
+} from './inspector-target'
+
+export * from './capability'
+export * from './errors'
+export * from './inspector-target'
+
+export interface InspectorDisposable {
+ [key: symbol]: unknown
+}
+
+export interface NativeInspectorApi {
+ url(): string | undefined
+ open(port?: number, host?: string, wait?: boolean): void | InspectorDisposable
+ close(): void
+ Network?: NativeNetworkApi
+}
+
+export interface NodeNativeAdapterDependencies extends TargetDiscoveryDependencies {
+ inspector?: NativeInspectorApi | null
+ inspectorAvailable?: boolean | (() => boolean)
+ execArgv?: readonly string[] | (() => readonly string[])
+ nodeVersion?: string | (() => string)
+ discovery?: TargetDiscoveryOptions
+}
+
+function resolveValue(value: T | (() => T)): T {
+ return typeof value === 'function' ? (value as () => T)() : value
+}
+
+function defaultInspectorAvailable() {
+ return process.features?.inspector !== false
+}
+
+function errorDiagnostic(
+ code: string,
+ message: string,
+ hint: string,
+ details?: Readonly>
+) {
+ return nativeDiagnostic(code, message, hint, details)
+}
+
+export class NodeNativeAdapter implements DebugAdapter {
+ readonly kind = 'native' as const
+ private readonly dependencies: NodeNativeAdapterDependencies
+
+ constructor(dependencies: NodeNativeAdapterDependencies = {}) {
+ this.dependencies = dependencies
+ }
+
+ probe(options: AdapterStartOptions = {}): AdapterProbe {
+ const diagnostics: Diagnostic[] = []
+ const inspector =
+ this.dependencies.inspector === undefined
+ ? (nodeInspector as unknown as NativeInspectorApi)
+ : this.dependencies.inspector
+ const inspectorAvailable = resolveValue(
+ this.dependencies.inspectorAvailable ?? defaultInspectorAvailable
+ )
+ const nodeVersionText = resolveValue(
+ this.dependencies.nodeVersion ?? (() => process.versions.node)
+ )
+ const version = parseNodeVersion(nodeVersionText)
+ const execArgv = resolveValue(this.dependencies.execArgv ?? (() => process.execArgv))
+ const network = inspector?.Network
+ const capabilities = getNativeCapabilities(version, network)
+
+ if (!inspectorAvailable || !inspector) {
+ diagnostics.push(
+ errorDiagnostic(
+ 'NND_NATIVE_INSPECTOR_UNAVAILABLE',
+ 'This Node.js runtime was built without Inspector support.',
+ 'Use an official Node.js build with Inspector support, or select the Legacy adapter.'
+ )
+ )
+ }
+
+ if (!supportsNativeNetworkInspection(version)) {
+ diagnostics.push(
+ errorDiagnostic(
+ 'NND_NATIVE_RUNTIME_UNSUPPORTED',
+ `Node.js ${nodeVersionText} does not support native network inspection.`,
+ 'Upgrade to Node.js 20.18+, 22.6+, or a newer release, or select the Legacy adapter.',
+ { nodeVersion: nodeVersionText }
+ )
+ )
+ }
+
+ if (!hasNativeInspectionFlag(execArgv)) {
+ diagnostics.push(
+ errorDiagnostic(
+ 'NND_NATIVE_FLAG_REQUIRED',
+ `Native network inspection requires ${NATIVE_NETWORK_INSPECTION_FLAG}.`,
+ `Restart with: node --inspect=0 ${NATIVE_NETWORK_INSPECTION_FLAG} `,
+ { execArgv: [...execArgv] }
+ )
+ )
+ }
+
+ if (inspector && !hasRequiredNativeMethods(network)) {
+ diagnostics.push(
+ errorDiagnostic(
+ 'NND_NATIVE_METHODS_UNAVAILABLE',
+ 'The required node:inspector Network methods are unavailable in this runtime.',
+ 'Upgrade Node.js or select the Legacy adapter.'
+ )
+ )
+ }
+
+ const missingCapabilities = getMissingCapabilities(capabilities, options.requiredCapabilities)
+ if (missingCapabilities.length > 0) {
+ diagnostics.push(
+ errorDiagnostic(
+ 'NND_NATIVE_REQUIRED_CAPABILITY_UNAVAILABLE',
+ `Native network inspection cannot provide: ${missingCapabilities.join(', ')}.`,
+ 'Remove the unsupported requirement or select an adapter that provides it.',
+ { missingCapabilities }
+ )
+ )
+ }
+
+ const runtimeSupported = supportsNativeNetworkInspection(version)
+ if (runtimeSupported && !isNativeAutoBaseline(version)) {
+ diagnostics.push(
+ nativeDiagnostic(
+ 'NND_NATIVE_AUTO_BASELINE_UNPROVEN',
+ `Node.js ${nodeVersionText} supports explicit Native mode but is below the proven Auto baseline.`,
+ 'Use Node.js 24.7+ for Auto selection, or explicitly select Native mode.',
+ { nodeVersion: nodeVersionText },
+ 'warn'
+ )
+ )
+ }
+
+ const available = !diagnostics.some((diagnostic) => diagnostic.level === 'error')
+ return {
+ kind: this.kind,
+ available,
+ autoSelectable: available && isNativeAutoBaseline(version),
+ capabilities,
+ diagnostics
+ }
+ }
+
+ async start(options: AdapterStartOptions = {}): Promise {
+ const probe = this.probe(options)
+ if (!probe.available) {
+ const diagnostic = probe.diagnostics.find((item) => item.level === 'error')!
+ throw new NodeNativeAdapterError(diagnostic, probe.diagnostics)
+ }
+
+ const inspector = (
+ this.dependencies.inspector === undefined
+ ? (nodeInspector as unknown as NativeInspectorApi)
+ : this.dependencies.inspector
+ )!
+ let inspectorUrl = inspector.url()
+ let owned = false
+ let disposable: void | InspectorDisposable = undefined
+
+ if (!inspectorUrl) {
+ const port = options.inspector?.port ?? 0
+ const host = options.inspector?.host ?? '127.0.0.1'
+ try {
+ disposable = inspector.open(port, host, false)
+ owned = true
+ inspectorUrl = inspector.url()
+ } catch (error) {
+ const diagnostic = errorDiagnostic(
+ 'NND_NATIVE_TARGET_OPEN_FAILED',
+ `Unable to open the Node Inspector on ${host}:${port}.`,
+ 'Choose another Inspector port or start Node with --inspect=0.',
+ { cause: error instanceof Error ? error.message : String(error), host, port }
+ )
+ throw new NodeNativeAdapterError(diagnostic, [...probe.diagnostics, diagnostic])
+ }
+
+ if (!inspectorUrl) {
+ this.closeOwnedInspector(inspector, disposable)
+ const diagnostic = errorDiagnostic(
+ 'NND_NATIVE_TARGET_URL_UNAVAILABLE',
+ 'Node Inspector opened without publishing a target URL.',
+ 'Start Node with --inspect=0 and retry.'
+ )
+ throw new NodeNativeAdapterError(diagnostic, [...probe.diagnostics, diagnostic])
+ }
+ }
+
+ try {
+ const target = await discoverInspectorTarget(
+ inspectorUrl,
+ this.dependencies.discovery,
+ this.dependencies
+ )
+ let disposed = false
+
+ return {
+ kind: this.kind,
+ capabilities: probe.capabilities,
+ target,
+ diagnostics: probe.diagnostics,
+ dispose: async () => {
+ if (disposed) return
+ disposed = true
+ if (owned) this.closeOwnedInspector(inspector, disposable)
+ }
+ }
+ } catch (error) {
+ if (owned) this.closeOwnedInspector(inspector, disposable)
+ throw error
+ }
+ }
+
+ private closeOwnedInspector(
+ inspector: NativeInspectorApi,
+ disposable: void | InspectorDisposable
+ ) {
+ const disposeSymbol = (Symbol as unknown as { dispose?: symbol }).dispose
+ const dispose = disposeSymbol && disposable?.[disposeSymbol]
+ if (typeof dispose === 'function') {
+ dispose.call(disposable)
+ return
+ }
+ inspector.close()
+ }
+}
diff --git a/packages/network-debugger/src/adapters/node-native/inspector-target.test.ts b/packages/network-debugger/src/adapters/node-native/inspector-target.test.ts
new file mode 100644
index 0000000..084dae6
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/inspector-target.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, test, vi } from 'vitest'
+import { NodeNativeAdapterError } from './errors'
+import { discoverInspectorTarget, getInspectorDiscoveryUrl } from './inspector-target'
+
+describe('Inspector target discovery', () => {
+ test('maps an Inspector WebSocket URL to the standard discovery endpoint', () => {
+ expect(getInspectorDiscoveryUrl('ws://127.0.0.1:9229/target-id')).toBe(
+ 'http://127.0.0.1:9229/json/list'
+ )
+ })
+
+ test('rejects non-Inspector protocols', () => {
+ expect(() => getInspectorDiscoveryUrl('http://127.0.0.1:9229/target-id')).toThrow(
+ 'Unsupported Inspector URL protocol'
+ )
+ expect(() => getInspectorDiscoveryUrl('wss://localhost:9443/target-id')).toThrow(
+ 'Unsupported Inspector URL protocol'
+ )
+ })
+
+ test('selects the descriptor matching the Inspector target id', async () => {
+ const requestJson = vi.fn().mockResolvedValue([
+ {
+ id: 'different',
+ title: 'different',
+ type: 'node',
+ url: 'file://',
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9229/different'
+ },
+ {
+ id: 'target-id',
+ title: 'node[123]',
+ type: 'node',
+ url: 'file:///app.js',
+ webSocketDebuggerUrl: 'ws://localhost:9229/target-id',
+ devtoolsFrontendUrl: 'devtools://native-target'
+ }
+ ])
+
+ const target = await discoverInspectorTarget(
+ 'ws://127.0.0.1:9229/target-id',
+ { attempts: 1 },
+ { requestJson }
+ )
+
+ expect(target).toEqual({
+ id: 'target-id',
+ title: 'node[123]',
+ type: 'node',
+ url: 'file:///app.js',
+ webSocketDebuggerUrl: 'ws://localhost:9229/target-id',
+ devtoolsFrontendUrl: 'devtools://native-target',
+ discoveryUrl: 'http://127.0.0.1:9229/json/list'
+ })
+ })
+
+ test('retries discovery with bounded attempts', async () => {
+ const requestJson = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('not listening'))
+ .mockResolvedValueOnce([
+ {
+ id: 'target-id',
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9229/target-id'
+ }
+ ])
+ const sleep = vi.fn().mockResolvedValue(undefined)
+
+ const target = await discoverInspectorTarget(
+ 'ws://127.0.0.1:9229/target-id',
+ { attempts: 2, requestTimeoutMs: 10, retryDelayMs: 1 },
+ { requestJson, sleep }
+ )
+
+ expect(target.id).toBe('target-id')
+ expect(requestJson).toHaveBeenCalledTimes(2)
+ expect(requestJson).toHaveBeenCalledWith('http://127.0.0.1:9229/json/list', 10)
+ expect(sleep).toHaveBeenCalledWith(1)
+ })
+
+ test('fails with a stable discovery code after exhausting retries', async () => {
+ const requestJson = vi.fn().mockRejectedValue(new Error('connection refused'))
+
+ await expect(
+ discoverInspectorTarget(
+ 'ws://127.0.0.1:9229/target-id',
+ { attempts: 2, retryDelayMs: 0 },
+ { requestJson, sleep: vi.fn().mockResolvedValue(undefined) }
+ )
+ ).rejects.toMatchObject({
+ code: 'NND_NATIVE_TARGET_DISCOVERY_FAILED'
+ })
+ expect(requestJson).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/packages/network-debugger/src/adapters/node-native/inspector-target.ts b/packages/network-debugger/src/adapters/node-native/inspector-target.ts
new file mode 100644
index 0000000..cea700d
--- /dev/null
+++ b/packages/network-debugger/src/adapters/node-native/inspector-target.ts
@@ -0,0 +1,178 @@
+import * as http from 'node:http'
+import type { DevtoolsTarget } from '../types'
+import { NodeNativeAdapterError, nativeDiagnostic } from './errors'
+
+export interface RawInspectorTarget {
+ id?: unknown
+ title?: unknown
+ type?: unknown
+ url?: unknown
+ webSocketDebuggerUrl?: unknown
+ devtoolsFrontendUrl?: unknown
+ devtoolsFrontendUrlCompat?: unknown
+}
+
+export interface TargetDiscoveryOptions {
+ attempts?: number
+ requestTimeoutMs?: number
+ retryDelayMs?: number
+}
+
+export interface TargetDiscoveryDependencies {
+ requestJson?: (url: string, timeoutMs: number) => Promise
+ sleep?: (milliseconds: number) => Promise
+}
+
+const DEFAULT_DISCOVERY_OPTIONS: Required = {
+ attempts: 5,
+ requestTimeoutMs: 500,
+ retryDelayMs: 25
+}
+
+function requestJson(url: string, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const parsed = new URL(url)
+ // Discovery targets an Inspector server that this process may own. A
+ // pooled keep-alive socket can outlive the response and make the
+ // synchronous inspector.close() wait on its own client connection,
+ // particularly on Windows. Give each probe an isolated, non-reusing
+ // agent so the response fully releases the target before disposal.
+ const request = http.get(parsed, { agent: false }, (response) => {
+ const chunks: Buffer[] = []
+ const socket = response.socket
+
+ response.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
+ response.once('error', reject)
+ response.on('end', () => {
+ const statusCode = response.statusCode ?? 0
+ let result: unknown
+ let failure: unknown
+ if (statusCode < 200 || statusCode >= 300) {
+ failure = new Error(`Inspector discovery returned HTTP ${statusCode}`)
+ } else {
+ try {
+ result = JSON.parse(Buffer.concat(chunks).toString('utf8'))
+ } catch (error) {
+ failure = error
+ }
+ }
+
+ const settle = () => (failure ? reject(failure) : resolve(result))
+ // `end` means the payload is complete, not that the underlying socket
+ // has left the Inspector server. Wait for actual close before allowing
+ // an owning adapter to call the synchronous inspector.close().
+ if (socket.destroyed) {
+ settle()
+ } else {
+ socket.once('close', settle)
+ socket.destroy()
+ }
+ })
+ })
+
+ request.setTimeout(timeoutMs, () => {
+ request.destroy(new Error(`Inspector discovery timed out after ${timeoutMs}ms`))
+ })
+ request.on('error', reject)
+ })
+}
+
+function sleep(milliseconds: number) {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
+}
+
+export function getInspectorDiscoveryUrl(webSocketUrl: string) {
+ const parsed = new URL(webSocketUrl)
+ // node:inspector exposes a local, non-TLS WebSocket endpoint. Supporting
+ // arbitrary remote WSS proxies here would make target ownership ambiguous.
+ if (parsed.protocol !== 'ws:') {
+ throw new Error(`Unsupported Inspector URL protocol: ${parsed.protocol}`)
+ }
+ parsed.protocol = 'http:'
+ parsed.pathname = '/json/list'
+ parsed.search = ''
+ parsed.hash = ''
+ return parsed.toString()
+}
+
+function isString(value: unknown): value is string {
+ return typeof value === 'string'
+}
+
+function normalizeTarget(
+ rawTarget: RawInspectorTarget,
+ discoveryUrl: string
+): DevtoolsTarget | null {
+ if (!isString(rawTarget.id) || !isString(rawTarget.webSocketDebuggerUrl)) return null
+
+ return {
+ id: rawTarget.id,
+ title: isString(rawTarget.title) ? rawTarget.title : 'Node.js',
+ type: isString(rawTarget.type) ? rawTarget.type : 'node',
+ url: isString(rawTarget.url) ? rawTarget.url : '',
+ webSocketDebuggerUrl: rawTarget.webSocketDebuggerUrl,
+ ...(isString(rawTarget.devtoolsFrontendUrl)
+ ? { devtoolsFrontendUrl: rawTarget.devtoolsFrontendUrl }
+ : {}),
+ ...(isString(rawTarget.devtoolsFrontendUrlCompat)
+ ? { devtoolsFrontendUrlCompat: rawTarget.devtoolsFrontendUrlCompat }
+ : {}),
+ discoveryUrl
+ }
+}
+
+function selectTarget(payload: unknown, inspectorUrl: string, discoveryUrl: string) {
+ if (!Array.isArray(payload)) return null
+
+ const targets = payload
+ .map((target) => normalizeTarget(target as RawInspectorTarget, discoveryUrl))
+ .filter((target): target is DevtoolsTarget => target !== null)
+
+ const inspectorId = new URL(inspectorUrl).pathname.replace(/^\//, '')
+ return (
+ targets.find((target) => target.webSocketDebuggerUrl === inspectorUrl) ??
+ targets.find((target) => target.id === inspectorId) ??
+ targets.find((target) => target.type === 'node') ??
+ targets[0] ??
+ null
+ )
+}
+
+export async function discoverInspectorTarget(
+ inspectorUrl: string,
+ options: TargetDiscoveryOptions = {},
+ dependencies: TargetDiscoveryDependencies = {}
+): Promise {
+ const normalizedOptions = { ...DEFAULT_DISCOVERY_OPTIONS, ...options }
+ const fetchJson = dependencies.requestJson ?? requestJson
+ const wait = dependencies.sleep ?? sleep
+ const discoveryUrl = getInspectorDiscoveryUrl(inspectorUrl)
+ let lastError: unknown
+
+ for (let attempt = 1; attempt <= normalizedOptions.attempts; attempt++) {
+ try {
+ const payload = await fetchJson(discoveryUrl, normalizedOptions.requestTimeoutMs)
+ const target = selectTarget(payload, inspectorUrl, discoveryUrl)
+ if (target) return target
+ throw new Error('Inspector discovery did not return a matching Node target')
+ } catch (error) {
+ lastError = error
+ if (attempt < normalizedOptions.attempts) {
+ await wait(normalizedOptions.retryDelayMs)
+ }
+ }
+ }
+
+ const diagnostic = nativeDiagnostic(
+ 'NND_NATIVE_TARGET_DISCOVERY_FAILED',
+ `Unable to discover the Node Inspector target at ${discoveryUrl}.`,
+ 'Verify that the Inspector endpoint is still running and retry.',
+ {
+ inspectorUrl,
+ discoveryUrl,
+ attempts: normalizedOptions.attempts,
+ cause: lastError instanceof Error ? lastError.message : String(lastError)
+ }
+ )
+ throw new NodeNativeAdapterError(diagnostic)
+}
diff --git a/packages/network-debugger/src/adapters/selector.test.ts b/packages/network-debugger/src/adapters/selector.test.ts
new file mode 100644
index 0000000..9bf4439
--- /dev/null
+++ b/packages/network-debugger/src/adapters/selector.test.ts
@@ -0,0 +1,479 @@
+import { describe, expect, test, vi } from 'vitest'
+import { AdapterSelectionError, AdapterSelector, type AdapterSelectionErrorCode } from './selector'
+import {
+ NETWORK_CAPABILITIES,
+ type AdapterKind,
+ type AdapterProbe,
+ type CapabilityMap,
+ type DebugAdapter,
+ type Diagnostic,
+ type NetworkCapability
+} from './types'
+
+const capabilities = (
+ enabled: readonly NetworkCapability[] = NETWORK_CAPABILITIES
+): CapabilityMap => {
+ const enabledSet = new Set(enabled)
+ return Object.fromEntries(
+ NETWORK_CAPABILITIES.map((capability) => [capability, enabledSet.has(capability)])
+ ) as unknown as CapabilityMap
+}
+
+const diagnostic = (code: string): Diagnostic => ({
+ code,
+ level: 'warn',
+ message: code
+})
+
+const adapter = (
+ kind: AdapterKind,
+ probe: AdapterProbe | (() => AdapterProbe | Promise)
+): DebugAdapter => ({
+ kind,
+ probe: vi.fn(typeof probe === 'function' ? probe : () => probe),
+ start: vi.fn(async () => ({
+ kind,
+ capabilities: capabilities(),
+ target: {
+ id: kind,
+ title: kind,
+ type: 'node',
+ url: 'file://',
+ webSocketDebuggerUrl: `ws://127.0.0.1/${kind}`,
+ discoveryUrl: 'http://127.0.0.1/json/list'
+ },
+ diagnostics: [],
+ dispose: async () => {}
+ })) as DebugAdapter['start']
+})
+
+const probe = (
+ kind: AdapterKind,
+ options: {
+ available?: boolean
+ enabled?: readonly NetworkCapability[]
+ diagnostics?: readonly Diagnostic[]
+ autoSelectable?: boolean
+ } = {}
+): AdapterProbe => ({
+ kind,
+ available: options.available ?? true,
+ ...(options.autoSelectable === undefined ? {} : { autoSelectable: options.autoSelectable }),
+ capabilities: capabilities(options.enabled),
+ diagnostics: options.diagnostics ?? []
+})
+
+const expectSelectionError = async (
+ promise: Promise,
+ code: AdapterSelectionErrorCode
+): Promise => {
+ try {
+ await promise
+ } catch (error) {
+ expect(error).toBeInstanceOf(AdapterSelectionError)
+ expect((error as AdapterSelectionError).code).toBe(code)
+ return error as AdapterSelectionError
+ }
+ throw new Error(`Expected ${code}`)
+}
+
+describe('AdapterSelector', () => {
+ test('auto prefers a capable native adapter and does not probe legacy', async () => {
+ const native = adapter('native', probe('native'))
+ const legacy = adapter('legacy', probe('legacy'))
+
+ const selection = await new AdapterSelector([legacy, native]).select({
+ mode: 'auto',
+ requiredCapabilities: ['fetch', 'responseBody']
+ })
+
+ expect(selection).toEqual({
+ adapter: native,
+ probe: probe('native')
+ })
+ expect(native.probe).toHaveBeenCalledOnce()
+ expect(native.probe).toHaveBeenCalledWith({
+ requiredCapabilities: ['fetch', 'responseBody']
+ })
+ expect(legacy.probe).not.toHaveBeenCalled()
+ })
+
+ test('auto is the default mode and supports a synchronous probe', async () => {
+ const nativeProbe = probe('native')
+ const native = adapter('native', () => nativeProbe)
+
+ const selection = await new AdapterSelector([native]).select()
+
+ expect(selection.adapter).toBe(native)
+ expect(selection.probe).toBe(nativeProbe)
+ expect(selection.fallbackReason).toBeUndefined()
+ })
+
+ test('supports an asynchronous probe and forwards inspector options', async () => {
+ const nativeProbe = probe('native')
+ const native = adapter('native', async () => nativeProbe)
+
+ const selection = await new AdapterSelector([native]).select({
+ mode: 'native',
+ inspector: { host: '127.0.0.1', port: 9230 }
+ })
+
+ expect(selection.probe).toBe(nativeProbe)
+ expect(native.probe).toHaveBeenCalledWith({
+ inspector: { host: '127.0.0.1', port: 9230 }
+ })
+ })
+
+ test('normalizes duplicate and unordered required capabilities deterministically', async () => {
+ const native = adapter('native', probe('native'))
+
+ await new AdapterSelector([native]).select({
+ mode: 'native',
+ requiredCapabilities: ['responseBody', 'http', 'responseBody']
+ })
+
+ expect(native.probe).toHaveBeenCalledWith({
+ requiredCapabilities: ['http', 'responseBody']
+ })
+ })
+
+ test('forced native fails with a stable unavailable error when not registered', async () => {
+ const error = await expectSelectionError(
+ new AdapterSelector([]).select({ mode: 'native' }),
+ 'NND_ADAPTER_UNAVAILABLE'
+ )
+
+ expect(error.message).toBe('Adapter "native" is not registered.')
+ expect(error.details).toEqual({
+ kind: 'native',
+ requiredCapabilities: [],
+ reason: 'not_registered'
+ })
+ })
+
+ test('forced native fails with a stable unavailable error and retains diagnostics', async () => {
+ const native = adapter(
+ 'native',
+ probe('native', {
+ available: false,
+ diagnostics: [diagnostic('NND_NATIVE_FLAG_MISSING')]
+ })
+ )
+
+ const error = await expectSelectionError(
+ new AdapterSelector([native]).select({ mode: 'native' }),
+ 'NND_ADAPTER_UNAVAILABLE'
+ )
+
+ expect(error.message).toBe('Adapter "native" is unavailable.')
+ expect(error.details).toEqual({
+ kind: 'native',
+ requiredCapabilities: [],
+ reason: 'unavailable',
+ diagnosticCodes: ['NND_NATIVE_FLAG_MISSING'],
+ diagnostics: [diagnostic('NND_NATIVE_FLAG_MISSING')]
+ })
+ })
+
+ test('forced legacy fails with missing capabilities in canonical order', async () => {
+ const legacy = adapter(
+ 'legacy',
+ probe('legacy', {
+ enabled: ['https']
+ })
+ )
+
+ const error = await expectSelectionError(
+ new AdapterSelector([legacy]).select({
+ mode: 'legacy',
+ requiredCapabilities: ['responseBody', 'http', 'fetch']
+ }),
+ 'NND_ADAPTER_CAPABILITY_MISSING'
+ )
+
+ expect(error.message).toBe(
+ 'Adapter "legacy" does not provide required capabilities: http, fetch, responseBody.'
+ )
+ expect(error.details).toEqual({
+ kind: 'legacy',
+ requiredCapabilities: ['http', 'fetch', 'responseBody'],
+ reason: 'missing_capabilities',
+ missingCapabilities: ['http', 'fetch', 'responseBody'],
+ diagnosticCodes: []
+ })
+ })
+
+ test('forced mode wraps a rejected probe in a stable error', async () => {
+ const native = adapter('native', async () => {
+ throw new Error('probe exploded')
+ })
+
+ const error = await expectSelectionError(
+ new AdapterSelector([native]).select({ mode: 'native' }),
+ 'NND_ADAPTER_PROBE_FAILED'
+ )
+
+ expect(error.message).toBe('Adapter "native" probe failed: probe exploded.')
+ expect(error.details).toEqual({
+ kind: 'native',
+ requiredCapabilities: [],
+ reason: 'probe_failed',
+ error: 'probe exploded'
+ })
+ })
+
+ test('forced mode rejects a probe whose kind does not match its adapter', async () => {
+ const native = adapter('native', probe('legacy'))
+
+ const error = await expectSelectionError(
+ new AdapterSelector([native]).select({ mode: 'native' }),
+ 'NND_INVALID_ADAPTER_PROBE'
+ )
+
+ expect(error.details).toEqual({
+ kind: 'native',
+ requiredCapabilities: [],
+ reason: 'invalid_probe',
+ error: 'Expected "native", received "legacy".'
+ })
+ })
+
+ test('auto falls back when native is unavailable with a deterministic diagnostic', async () => {
+ const native = adapter(
+ 'native',
+ probe('native', {
+ available: false,
+ diagnostics: [diagnostic('NND_NATIVE_FLAG_MISSING')]
+ })
+ )
+ const legacyProbe = probe('legacy')
+ const legacy = adapter('legacy', legacyProbe)
+
+ const selection = await new AdapterSelector([native, legacy]).select({
+ requiredCapabilities: ['fetch']
+ })
+
+ expect(selection.adapter).toBe(legacy)
+ expect(selection.probe).toBe(legacyProbe)
+ expect(selection.fallbackReason).toEqual({
+ code: 'NND_AUTO_FALLBACK',
+ level: 'warn',
+ message: 'Native adapter cannot satisfy this selection; using legacy adapter.',
+ hint: 'Use mode "native" to fail instead of falling back.',
+ details: {
+ from: 'native',
+ to: 'legacy',
+ reason: 'unavailable',
+ requiredCapabilities: ['fetch'],
+ diagnosticCodes: ['NND_NATIVE_FLAG_MISSING'],
+ diagnostics: [diagnostic('NND_NATIVE_FLAG_MISSING')]
+ }
+ })
+ })
+
+ test('auto falls back when native is available but not auto-selectable', async () => {
+ const baselineDiagnostic = diagnostic('NND_NATIVE_AUTO_BASELINE_UNMET')
+ const nativeProbe = probe('native', {
+ autoSelectable: false,
+ diagnostics: [baselineDiagnostic]
+ })
+ const native = adapter('native', nativeProbe)
+ const legacy = adapter('legacy', probe('legacy'))
+
+ const selection = await new AdapterSelector([native, legacy]).select({
+ requiredCapabilities: ['http', 'fetch']
+ })
+
+ expect(selection.adapter).toBe(legacy)
+ expect(selection.fallbackReason?.details).toEqual({
+ from: 'native',
+ to: 'legacy',
+ reason: 'not_auto_selectable',
+ requiredCapabilities: ['http', 'fetch'],
+ diagnosticCodes: ['NND_NATIVE_AUTO_BASELINE_UNMET'],
+ diagnostics: [baselineDiagnostic]
+ })
+ })
+
+ test('forced native ignores autoSelectable while still enforcing capabilities', async () => {
+ const nativeProbe = probe('native', {
+ autoSelectable: false,
+ enabled: ['http'],
+ diagnostics: [diagnostic('NND_NATIVE_AUTO_BASELINE_UNMET')]
+ })
+ const native = adapter('native', nativeProbe)
+
+ const selection = await new AdapterSelector([native]).select({
+ mode: 'native',
+ requiredCapabilities: ['http']
+ })
+
+ expect(selection).toEqual({ adapter: native, probe: nativeProbe })
+ })
+
+ test('auto falls back when native lacks requirements and explains which ones', async () => {
+ const native = adapter(
+ 'native',
+ probe('native', {
+ enabled: ['http']
+ })
+ )
+ const legacy = adapter('legacy', probe('legacy'))
+
+ const selection = await new AdapterSelector([native, legacy]).select({
+ requiredCapabilities: ['responseBody', 'http', 'fetch']
+ })
+
+ expect(selection.adapter).toBe(legacy)
+ expect(selection.fallbackReason?.details).toEqual({
+ from: 'native',
+ to: 'legacy',
+ reason: 'missing_capabilities',
+ requiredCapabilities: ['http', 'fetch', 'responseBody'],
+ missingCapabilities: ['fetch', 'responseBody'],
+ diagnosticCodes: []
+ })
+ })
+
+ test('auto falls back when native is not registered', async () => {
+ const legacy = adapter('legacy', probe('legacy'))
+
+ const selection = await new AdapterSelector([legacy]).select()
+
+ expect(selection.adapter).toBe(legacy)
+ expect(selection.fallbackReason?.details).toEqual({
+ from: 'native',
+ to: 'legacy',
+ reason: 'not_registered',
+ requiredCapabilities: []
+ })
+ })
+
+ test('auto falls back after a native probe failure', async () => {
+ const native = adapter('native', () => {
+ throw 'synchronous failure'
+ })
+ const legacy = adapter('legacy', probe('legacy'))
+
+ const selection = await new AdapterSelector([native, legacy]).select()
+
+ expect(selection.adapter).toBe(legacy)
+ expect(selection.fallbackReason?.details).toEqual({
+ from: 'native',
+ to: 'legacy',
+ reason: 'probe_failed',
+ requiredCapabilities: [],
+ error: 'synchronous failure'
+ })
+ })
+
+ test('auto fails explicitly with deterministic attempts when neither adapter is capable', async () => {
+ const native = adapter(
+ 'native',
+ probe('native', {
+ available: false,
+ diagnostics: [diagnostic('NND_NATIVE_UNAVAILABLE')]
+ })
+ )
+ const legacy = adapter(
+ 'legacy',
+ probe('legacy', {
+ enabled: ['http']
+ })
+ )
+
+ const error = await expectSelectionError(
+ new AdapterSelector([legacy, native]).select({
+ requiredCapabilities: ['fetch', 'responseBody']
+ }),
+ 'NND_NO_CAPABLE_ADAPTER'
+ )
+
+ expect(error.message).toBe(
+ 'No available adapter satisfies required capabilities: fetch, responseBody.'
+ )
+ expect(error.details).toEqual({
+ requiredCapabilities: ['fetch', 'responseBody'],
+ attempts: [
+ {
+ kind: 'native',
+ reason: 'unavailable',
+ diagnosticCodes: ['NND_NATIVE_UNAVAILABLE'],
+ diagnostics: [diagnostic('NND_NATIVE_UNAVAILABLE')]
+ },
+ {
+ kind: 'legacy',
+ reason: 'missing_capabilities',
+ missingCapabilities: ['fetch', 'responseBody'],
+ diagnosticCodes: []
+ }
+ ]
+ })
+ })
+
+ test('auto does not select a legacy adapter that explicitly opts out', async () => {
+ const legacyDiagnostic = diagnostic('NND_LEGACY_AUTO_DISABLED')
+ const legacy = adapter(
+ 'legacy',
+ probe('legacy', {
+ autoSelectable: false,
+ diagnostics: [legacyDiagnostic]
+ })
+ )
+
+ const error = await expectSelectionError(
+ new AdapterSelector([legacy]).select(),
+ 'NND_NO_CAPABLE_ADAPTER'
+ )
+
+ expect(error.details).toEqual({
+ requiredCapabilities: [],
+ attempts: [
+ { kind: 'native', reason: 'not_registered' },
+ {
+ kind: 'legacy',
+ reason: 'not_auto_selectable',
+ diagnosticCodes: ['NND_LEGACY_AUTO_DISABLED'],
+ diagnostics: [legacyDiagnostic]
+ }
+ ]
+ })
+ })
+
+ test('auto reports no available adapter when none are registered', async () => {
+ const error = await expectSelectionError(
+ new AdapterSelector([]).select(),
+ 'NND_NO_CAPABLE_ADAPTER'
+ )
+
+ expect(error.message).toBe('No available adapter satisfies required capabilities: (none).')
+ expect(error.details).toEqual({
+ requiredCapabilities: [],
+ attempts: [
+ { kind: 'native', reason: 'not_registered' },
+ { kind: 'legacy', reason: 'not_registered' }
+ ]
+ })
+ })
+
+ test('constructor rejects duplicate adapter kinds deterministically', () => {
+ const first = adapter('native', probe('native'))
+ const second = adapter('native', probe('native'))
+
+ expect(() => new AdapterSelector([first, second])).toThrowError(
+ expect.objectContaining({
+ code: 'NND_DUPLICATE_ADAPTER',
+ message: 'Adapter "native" is registered more than once.',
+ details: { kind: 'native' }
+ })
+ )
+ })
+
+ test('selection never starts an adapter', async () => {
+ const native = adapter('native', probe('native'))
+
+ await new AdapterSelector([native]).select({ mode: 'native' })
+
+ expect(native.start).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/network-debugger/src/adapters/selector.ts b/packages/network-debugger/src/adapters/selector.ts
new file mode 100644
index 0000000..557dd87
--- /dev/null
+++ b/packages/network-debugger/src/adapters/selector.ts
@@ -0,0 +1,350 @@
+import {
+ NETWORK_CAPABILITIES,
+ type AdapterKind,
+ type AdapterMode,
+ type AdapterProbe,
+ type AdapterSelection,
+ type AdapterStartOptions,
+ type DebugAdapter,
+ type Diagnostic,
+ type NetworkCapability
+} from './types'
+
+export interface AdapterSelectionOptions extends AdapterStartOptions {
+ /**
+ * `auto` prefers the native adapter and falls back to legacy only when the
+ * native adapter is unavailable or cannot provide every required capability.
+ *
+ * @default 'auto'
+ */
+ mode?: AdapterMode
+}
+
+export type AdapterSelectionErrorCode =
+ | 'NND_DUPLICATE_ADAPTER'
+ | 'NND_ADAPTER_UNAVAILABLE'
+ | 'NND_ADAPTER_PROBE_FAILED'
+ | 'NND_ADAPTER_CAPABILITY_MISSING'
+ | 'NND_INVALID_ADAPTER_PROBE'
+ | 'NND_NO_CAPABLE_ADAPTER'
+
+export type AdapterSelectionFailureReason =
+ | 'not_registered'
+ | 'probe_failed'
+ | 'unavailable'
+ | 'not_auto_selectable'
+ | 'missing_capabilities'
+ | 'invalid_probe'
+
+export interface AdapterSelectionAttempt {
+ kind: AdapterKind
+ reason: AdapterSelectionFailureReason
+ missingCapabilities?: readonly NetworkCapability[]
+ diagnosticCodes?: readonly string[]
+ diagnostics?: readonly Diagnostic[]
+ error?: string
+}
+
+export class AdapterSelectionError extends Error {
+ readonly code: AdapterSelectionErrorCode
+ readonly details: Readonly>
+ readonly cause?: unknown
+
+ constructor(
+ code: AdapterSelectionErrorCode,
+ message: string,
+ details: Readonly> = {},
+ cause?: unknown
+ ) {
+ super(message)
+ this.name = 'AdapterSelectionError'
+ this.code = code
+ this.details = details
+ this.cause = cause
+ }
+}
+
+interface SuccessfulProbe {
+ adapter: DebugAdapter
+ probe: AdapterProbe
+}
+
+interface FailedProbe {
+ adapter?: DebugAdapter
+ attempt: AdapterSelectionAttempt
+}
+
+type ProbeResult = SuccessfulProbe | FailedProbe
+
+const isSuccessfulProbe = (result: ProbeResult): result is SuccessfulProbe => 'probe' in result
+
+const normalizeRequiredCapabilities = (
+ capabilities: readonly NetworkCapability[] | undefined
+): readonly NetworkCapability[] => {
+ if (!capabilities?.length) return []
+
+ const requested = new Set(capabilities)
+ return NETWORK_CAPABILITIES.filter((capability) => requested.has(capability))
+}
+
+const getMissingCapabilities = (
+ probe: AdapterProbe,
+ requiredCapabilities: readonly NetworkCapability[]
+): readonly NetworkCapability[] =>
+ requiredCapabilities.filter((capability) => !probe.capabilities[capability])
+
+const errorMessage = (error: unknown): string => {
+ if (error instanceof Error) return error.message
+ return String(error)
+}
+
+const diagnosticCodes = (probe: AdapterProbe): readonly string[] =>
+ probe.diagnostics.map((diagnostic) => diagnostic.code)
+
+/**
+ * Selects a runtime adapter without starting it.
+ *
+ * Selection is deliberately deterministic: adapters are addressed by kind,
+ * `auto` always probes native first, and capability lists use the canonical
+ * NETWORK_CAPABILITIES order.
+ */
+export class AdapterSelector {
+ private readonly adapters: ReadonlyMap
+
+ constructor(adapters: readonly DebugAdapter[]) {
+ const byKind = new Map()
+
+ for (const adapter of adapters) {
+ if (byKind.has(adapter.kind)) {
+ throw new AdapterSelectionError(
+ 'NND_DUPLICATE_ADAPTER',
+ `Adapter "${adapter.kind}" is registered more than once.`,
+ { kind: adapter.kind }
+ )
+ }
+ byKind.set(adapter.kind, adapter)
+ }
+
+ this.adapters = byKind
+ }
+
+ async select(options: AdapterSelectionOptions = {}): Promise {
+ const mode = options.mode ?? 'auto'
+ const requiredCapabilities = normalizeRequiredCapabilities(options.requiredCapabilities)
+ const probeOptions: AdapterStartOptions = {
+ ...(options.inspector ? { inspector: options.inspector } : {}),
+ ...(requiredCapabilities.length > 0 ? { requiredCapabilities } : {})
+ }
+
+ if (mode === 'native' || mode === 'legacy') {
+ return this.selectForced(mode, requiredCapabilities, probeOptions)
+ }
+
+ return this.selectAuto(requiredCapabilities, probeOptions)
+ }
+
+ private async selectForced(
+ kind: AdapterKind,
+ requiredCapabilities: readonly NetworkCapability[],
+ probeOptions: AdapterStartOptions
+ ): Promise {
+ const result = await this.probe(kind, requiredCapabilities, probeOptions)
+
+ if (isSuccessfulProbe(result)) {
+ return {
+ adapter: result.adapter,
+ probe: result.probe
+ }
+ }
+
+ const { attempt } = result
+ const details = {
+ ...attempt,
+ requiredCapabilities
+ }
+
+ switch (attempt.reason) {
+ case 'not_registered':
+ throw new AdapterSelectionError(
+ 'NND_ADAPTER_UNAVAILABLE',
+ `Adapter "${kind}" is not registered.`,
+ details
+ )
+ case 'unavailable':
+ throw new AdapterSelectionError(
+ 'NND_ADAPTER_UNAVAILABLE',
+ `Adapter "${kind}" is unavailable.`,
+ details
+ )
+ case 'missing_capabilities':
+ throw new AdapterSelectionError(
+ 'NND_ADAPTER_CAPABILITY_MISSING',
+ `Adapter "${kind}" does not provide required capabilities: ${attempt.missingCapabilities!.join(', ')}.`,
+ details
+ )
+ case 'not_auto_selectable':
+ // This result is only produced by auto-mode probes.
+ throw new AdapterSelectionError(
+ 'NND_ADAPTER_UNAVAILABLE',
+ `Adapter "${kind}" is not eligible for automatic selection.`,
+ details
+ )
+ case 'invalid_probe':
+ throw new AdapterSelectionError(
+ 'NND_INVALID_ADAPTER_PROBE',
+ `Adapter "${kind}" returned a probe for a different adapter kind.`,
+ details
+ )
+ case 'probe_failed':
+ throw new AdapterSelectionError(
+ 'NND_ADAPTER_PROBE_FAILED',
+ `Adapter "${kind}" probe failed: ${attempt.error}.`,
+ details,
+ attempt.error
+ )
+ }
+ }
+
+ private async selectAuto(
+ requiredCapabilities: readonly NetworkCapability[],
+ probeOptions: AdapterStartOptions
+ ): Promise {
+ const nativeResult = await this.probe('native', requiredCapabilities, probeOptions, true)
+
+ if (isSuccessfulProbe(nativeResult)) {
+ return {
+ adapter: nativeResult.adapter,
+ probe: nativeResult.probe
+ }
+ }
+
+ const legacyResult = await this.probe('legacy', requiredCapabilities, probeOptions, true)
+
+ if (isSuccessfulProbe(legacyResult)) {
+ return {
+ adapter: legacyResult.adapter,
+ probe: legacyResult.probe,
+ fallbackReason: this.createFallbackDiagnostic(nativeResult.attempt, requiredCapabilities)
+ }
+ }
+
+ const attempts = [nativeResult.attempt, legacyResult.attempt]
+ const requirementText = requiredCapabilities.length ? requiredCapabilities.join(', ') : '(none)'
+
+ throw new AdapterSelectionError(
+ 'NND_NO_CAPABLE_ADAPTER',
+ `No available adapter satisfies required capabilities: ${requirementText}.`,
+ {
+ requiredCapabilities,
+ attempts
+ }
+ )
+ }
+
+ private async probe(
+ kind: AdapterKind,
+ requiredCapabilities: readonly NetworkCapability[],
+ options: AdapterStartOptions,
+ requireAutoSelectable = false
+ ): Promise {
+ const adapter = this.adapters.get(kind)
+
+ if (!adapter) {
+ return {
+ attempt: {
+ kind,
+ reason: 'not_registered'
+ }
+ }
+ }
+
+ let probe: AdapterProbe
+ try {
+ probe = await adapter.probe(options)
+ } catch (error) {
+ return {
+ adapter,
+ attempt: {
+ kind,
+ reason: 'probe_failed',
+ error: errorMessage(error)
+ }
+ }
+ }
+
+ if (probe.kind !== kind) {
+ return {
+ adapter,
+ attempt: {
+ kind,
+ reason: 'invalid_probe',
+ error: `Expected "${kind}", received "${probe.kind}".`
+ }
+ }
+ }
+
+ if (!probe.available) {
+ return {
+ adapter,
+ attempt: {
+ kind,
+ reason: 'unavailable',
+ diagnosticCodes: diagnosticCodes(probe),
+ ...(probe.diagnostics.length ? { diagnostics: probe.diagnostics } : {})
+ }
+ }
+ }
+
+ if (requireAutoSelectable && probe.autoSelectable === false) {
+ return {
+ adapter,
+ attempt: {
+ kind,
+ reason: 'not_auto_selectable',
+ diagnosticCodes: diagnosticCodes(probe),
+ ...(probe.diagnostics.length ? { diagnostics: probe.diagnostics } : {})
+ }
+ }
+ }
+
+ const missingCapabilities = getMissingCapabilities(probe, requiredCapabilities)
+ if (missingCapabilities.length > 0) {
+ return {
+ adapter,
+ attempt: {
+ kind,
+ reason: 'missing_capabilities',
+ missingCapabilities,
+ diagnosticCodes: diagnosticCodes(probe),
+ ...(probe.diagnostics.length ? { diagnostics: probe.diagnostics } : {})
+ }
+ }
+ }
+
+ return { adapter, probe }
+ }
+
+ private createFallbackDiagnostic(
+ attempt: AdapterSelectionAttempt,
+ requiredCapabilities: readonly NetworkCapability[]
+ ): Diagnostic {
+ return {
+ code: 'NND_AUTO_FALLBACK',
+ level: 'warn',
+ message: 'Native adapter cannot satisfy this selection; using legacy adapter.',
+ hint: 'Use mode "native" to fail instead of falling back.',
+ details: {
+ from: 'native',
+ to: 'legacy',
+ reason: attempt.reason,
+ requiredCapabilities,
+ ...(attempt.missingCapabilities
+ ? { missingCapabilities: attempt.missingCapabilities }
+ : {}),
+ ...(attempt.diagnosticCodes ? { diagnosticCodes: attempt.diagnosticCodes } : {}),
+ ...(attempt.diagnostics ? { diagnostics: attempt.diagnostics } : {}),
+ ...(attempt.error ? { error: attempt.error } : {})
+ }
+ }
+ }
+}
diff --git a/packages/network-debugger/src/adapters/types.ts b/packages/network-debugger/src/adapters/types.ts
new file mode 100644
index 0000000..4582d48
--- /dev/null
+++ b/packages/network-debugger/src/adapters/types.ts
@@ -0,0 +1,81 @@
+export const NETWORK_CAPABILITIES = [
+ 'http',
+ 'https',
+ 'fetch',
+ 'http2',
+ 'responseBody',
+ 'requestBody',
+ 'websocketLifecycle',
+ 'websocketFrames',
+ 'sseMessages',
+ 'initiator'
+] as const
+
+export type NetworkCapability = (typeof NETWORK_CAPABILITIES)[number]
+export type CapabilityMap = Readonly>
+export type AdapterKind = 'native' | 'legacy'
+export type AdapterMode = 'auto' | AdapterKind
+
+export type DiagnosticLevel = 'info' | 'warn' | 'error'
+
+export interface Diagnostic {
+ code: string
+ level: DiagnosticLevel
+ message: string
+ hint?: string
+ details?: Readonly>
+}
+
+export interface DevtoolsTarget {
+ id: string
+ title: string
+ type: string
+ url: string
+ webSocketDebuggerUrl: string
+ devtoolsFrontendUrl?: string
+ devtoolsFrontendUrlCompat?: string
+ discoveryUrl: string
+}
+
+export interface InspectorTargetOptions {
+ host?: string
+ port?: number
+}
+
+export interface AdapterStartOptions {
+ inspector?: InspectorTargetOptions
+ requiredCapabilities?: readonly NetworkCapability[]
+}
+
+export interface AdapterProbe {
+ kind: AdapterKind
+ available: boolean
+ /** Whether Auto mode may select this adapter as a proven default baseline. */
+ autoSelectable?: boolean
+ capabilities: CapabilityMap
+ diagnostics: readonly Diagnostic[]
+}
+
+export interface AdapterSession {
+ kind: AdapterKind
+ capabilities: CapabilityMap
+ target: DevtoolsTarget
+ diagnostics: readonly Diagnostic[]
+ /** Optional live diagnostics emitted after the initial target is ready. */
+ onDiagnostic?(listener: (diagnostic: Diagnostic) => void): () => void
+ /** Optional terminal backend failure emitted after initial readiness. */
+ onFailure?(listener: (error: Error) => void): () => void
+ dispose(): Promise
+}
+
+export interface DebugAdapter {
+ readonly kind: AdapterKind
+ probe(options?: AdapterStartOptions): AdapterProbe | Promise
+ start(options?: AdapterStartOptions): Promise
+}
+
+export interface AdapterSelection {
+ adapter: DebugAdapter
+ probe: AdapterProbe
+ fallbackReason?: Diagnostic
+}
diff --git a/packages/network-debugger/src/cli/args.test.ts b/packages/network-debugger/src/cli/args.test.ts
new file mode 100644
index 0000000..2e6efe4
--- /dev/null
+++ b/packages/network-debugger/src/cli/args.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest'
+import { parseCliArgs } from './args'
+
+describe('CLI argument parsing', () => {
+ it('parses the documented dev syntax and preserves application arguments', () => {
+ expect(
+ parseCliArgs([
+ 'dev',
+ '--open',
+ '--no-wait',
+ '--watch',
+ '--runner',
+ 'tsx',
+ '--mode=native',
+ '--inspect-port',
+ '0',
+ '--require=responseBody',
+ 'src/app.ts',
+ '--',
+ '--port',
+ '3000'
+ ])
+ ).toEqual({
+ command: 'dev',
+ entry: 'src/app.ts',
+ applicationArgs: ['--port', '3000'],
+ config: {
+ open: true,
+ wait: false,
+ watch: true,
+ runner: 'tsx',
+ mode: 'native',
+ inspector: { port: 0 },
+ requiredCapabilities: ['responseBody']
+ }
+ })
+ })
+
+ it('parses doctor JSON, config, and bounded probe wait', () => {
+ expect(
+ parseCliArgs([
+ 'doctor',
+ '--json',
+ '--probe-wait=1500',
+ '--config',
+ 'custom.mjs',
+ '--mode',
+ 'legacy'
+ ])
+ ).toEqual({
+ command: 'doctor',
+ json: true,
+ probeWaitMs: 1500,
+ configFile: 'custom.mjs',
+ config: { mode: 'legacy' }
+ })
+ })
+
+ it('returns help and version commands', () => {
+ expect(parseCliArgs([])).toEqual({ command: 'help' })
+ expect(parseCliArgs(['--version'])).toEqual({ command: 'version' })
+ })
+
+ it('parses replay dry-run and execution controls', () => {
+ expect(
+ parseCliArgs([
+ 'replay',
+ '--dry-run',
+ '--json',
+ '--stop-on-error',
+ '--timeout=2500',
+ '.nnd/session'
+ ])
+ ).toEqual({
+ command: 'replay',
+ source: '.nnd/session',
+ dryRun: true,
+ stopOnError: true,
+ timeoutMs: 2500,
+ json: true
+ })
+ })
+
+ it('uses stable errors for missing entries and invalid flags', () => {
+ expect(() => parseCliArgs(['dev', '--open'])).toThrowError(
+ expect.objectContaining({ code: 'NND_CLI_USAGE' })
+ )
+ expect(() => parseCliArgs(['dev', '--runner', 'bun', 'app.js'])).toThrowError(
+ expect.objectContaining({ code: 'NND_CLI_INVALID_OPTION' })
+ )
+ expect(() => parseCliArgs(['doctor', '--probe-wait', '-1'])).toThrowError(
+ expect.objectContaining({ code: 'NND_CLI_INVALID_OPTION' })
+ )
+ expect(() => parseCliArgs(['replay', '--dry-run'])).toThrowError(
+ expect.objectContaining({ code: 'NND_CLI_USAGE' })
+ )
+ })
+})
diff --git a/packages/network-debugger/src/cli/args.ts b/packages/network-debugger/src/cli/args.ts
new file mode 100644
index 0000000..ea6924a
--- /dev/null
+++ b/packages/network-debugger/src/cli/args.ts
@@ -0,0 +1,348 @@
+import type { AdapterMode, NetworkCapability } from '../adapters/types'
+import { NETWORK_CAPABILITIES } from '../adapters/types'
+import type { NndConfig, NndRunner } from '../config'
+import { NndCliError } from './errors'
+
+export interface DevInvocation {
+ command: 'dev'
+ entry: string
+ applicationArgs: readonly string[]
+ config: NndConfig
+ configFile?: string
+}
+
+export interface DoctorInvocation {
+ command: 'doctor'
+ json: boolean
+ probeWaitMs: number
+ config: NndConfig
+ configFile?: string
+}
+
+export interface ReplayInvocation {
+ command: 'replay'
+ source: string
+ dryRun: boolean
+ stopOnError: boolean
+ timeoutMs?: number
+ json: boolean
+}
+
+export type CliInvocation =
+ | DevInvocation
+ | DoctorInvocation
+ | ReplayInvocation
+ | { command: 'help' }
+ | { command: 'version' }
+
+const MODES = new Set(['auto', 'native', 'legacy'])
+const RUNNERS = new Set(['node', 'tsx'])
+const CAPABILITIES = new Set(NETWORK_CAPABILITIES)
+
+interface ParsedOption {
+ name: string
+ inlineValue?: string
+}
+
+function splitOption(argument: string): ParsedOption {
+ const equals = argument.indexOf('=')
+ if (equals === -1) return { name: argument }
+ return { name: argument.slice(0, equals), inlineValue: argument.slice(equals + 1) }
+}
+
+function requiredValue(
+ option: ParsedOption,
+ args: readonly string[],
+ index: number
+): { value: string; consumed: number } {
+ if (option.inlineValue !== undefined) {
+ if (!option.inlineValue) {
+ throw new NndCliError('NND_CLI_USAGE', `${option.name} requires a value.`)
+ }
+ return { value: option.inlineValue, consumed: 0 }
+ }
+ const value = args[index + 1]
+ if (value === undefined || value === '--') {
+ throw new NndCliError('NND_CLI_USAGE', `${option.name} requires a value.`)
+ }
+ return { value, consumed: 1 }
+}
+
+function parseNonNegativeInteger(value: string, option: string, maximum?: number): number {
+ const number = Number(value)
+ if (!Number.isInteger(number) || number < 0 || (maximum !== undefined && number > maximum)) {
+ throw new NndCliError(
+ 'NND_CLI_INVALID_OPTION',
+ `${option} must be an integer from 0${maximum === undefined ? '' : ` to ${maximum}`}.`,
+ { option, value }
+ )
+ }
+ return number
+}
+
+interface CommonParseState {
+ config: NndConfig
+ configFile?: string
+}
+
+function applyCommonOption(
+ option: ParsedOption,
+ args: readonly string[],
+ index: number,
+ state: CommonParseState
+): number | undefined {
+ switch (option.name) {
+ case '--open':
+ state.config.open = true
+ return 0
+ case '--no-open':
+ state.config.open = false
+ return 0
+ case '--wait':
+ state.config.wait = true
+ return 0
+ case '--no-wait':
+ state.config.wait = false
+ return 0
+ case '--watch':
+ state.config.watch = true
+ return 0
+ case '--no-watch':
+ state.config.watch = false
+ return 0
+ case '--runner': {
+ const parsed = requiredValue(option, args, index)
+ if (!RUNNERS.has(parsed.value as NndRunner)) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--runner must be node or tsx.', {
+ value: parsed.value
+ })
+ }
+ state.config.runner = parsed.value as NndRunner
+ return parsed.consumed
+ }
+ case '--mode': {
+ const parsed = requiredValue(option, args, index)
+ if (!MODES.has(parsed.value as AdapterMode)) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--mode must be auto, native, or legacy.', {
+ value: parsed.value
+ })
+ }
+ state.config.mode = parsed.value as AdapterMode
+ return parsed.consumed
+ }
+ case '--config': {
+ const parsed = requiredValue(option, args, index)
+ state.configFile = parsed.value
+ return parsed.consumed
+ }
+ case '--inspect-host': {
+ const parsed = requiredValue(option, args, index)
+ if (!parsed.value) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--inspect-host cannot be empty.')
+ }
+ state.config.inspector = { ...state.config.inspector, host: parsed.value }
+ return parsed.consumed
+ }
+ case '--inspect-port': {
+ const parsed = requiredValue(option, args, index)
+ state.config.inspector = {
+ ...state.config.inspector,
+ port: parseNonNegativeInteger(parsed.value, '--inspect-port', 65_535)
+ }
+ return parsed.consumed
+ }
+ case '--require':
+ case '--required-capability': {
+ const parsed = requiredValue(option, args, index)
+ if (!CAPABILITIES.has(parsed.value)) {
+ throw new NndCliError(
+ 'NND_CLI_INVALID_OPTION',
+ `Unknown network capability: ${parsed.value}.`,
+ { value: parsed.value, allowed: [...NETWORK_CAPABILITIES] }
+ )
+ }
+ state.config.requiredCapabilities = [
+ ...(state.config.requiredCapabilities ?? []),
+ parsed.value as NetworkCapability
+ ]
+ return parsed.consumed
+ }
+ default:
+ return undefined
+ }
+}
+
+function parseDev(args: readonly string[]): DevInvocation {
+ const state: CommonParseState = { config: {} }
+ let entry: string | undefined
+ let applicationArgs: string[] = []
+
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index]
+ if (argument === '--') {
+ if (!entry) {
+ entry = args[index + 1]
+ if (!entry) break
+ applicationArgs = args.slice(index + 2)
+ } else {
+ applicationArgs = args.slice(index + 1)
+ }
+ break
+ }
+
+ if (!entry && argument.startsWith('-')) {
+ if (argument === '-h' || argument === '--help') {
+ throw new NndCliError('NND_CLI_USAGE', 'Use `nnd help` for usage.')
+ }
+ const option = splitOption(argument)
+ const consumed = applyCommonOption(option, args, index, state)
+ if (consumed === undefined) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', `Unknown option: ${option.name}.`, {
+ option: option.name
+ })
+ }
+ index += consumed
+ continue
+ }
+
+ if (!entry) {
+ entry = argument
+ } else {
+ applicationArgs = args.slice(index)
+ break
+ }
+ }
+
+ if (!entry) {
+ throw new NndCliError('NND_CLI_USAGE', 'nnd dev requires an entry file.')
+ }
+
+ return {
+ command: 'dev',
+ entry,
+ applicationArgs,
+ config: state.config,
+ ...(state.configFile ? { configFile: state.configFile } : {})
+ }
+}
+
+function parseDoctor(args: readonly string[]): DoctorInvocation {
+ const state: CommonParseState = { config: {} }
+ let json = false
+ let probeWaitMs = 0
+
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index]
+ const option = splitOption(argument)
+ if (option.name === '--json') {
+ if (option.inlineValue !== undefined) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--json does not accept a value.')
+ }
+ json = true
+ continue
+ }
+ if (option.name === '--probe-wait') {
+ const parsed = requiredValue(option, args, index)
+ probeWaitMs = parseNonNegativeInteger(parsed.value, '--probe-wait', 60_000)
+ index += parsed.consumed
+ continue
+ }
+
+ const consumed = applyCommonOption(option, args, index, state)
+ if (consumed === undefined) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', `Unknown doctor option: ${option.name}.`, {
+ option: option.name
+ })
+ }
+ index += consumed
+ }
+
+ return {
+ command: 'doctor',
+ json,
+ probeWaitMs,
+ config: state.config,
+ ...(state.configFile ? { configFile: state.configFile } : {})
+ }
+}
+
+function parseReplay(args: readonly string[]): ReplayInvocation {
+ let source: string | undefined
+ let dryRun = false
+ let stopOnError = false
+ let timeoutMs: number | undefined
+ let json = false
+
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index]
+ if (!source && argument.startsWith('-')) {
+ const option = splitOption(argument)
+ if (option.name === '--dry-run') {
+ if (option.inlineValue !== undefined) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--dry-run does not accept a value.')
+ }
+ dryRun = true
+ continue
+ }
+ if (option.name === '--stop-on-error') {
+ if (option.inlineValue !== undefined) {
+ throw new NndCliError(
+ 'NND_CLI_INVALID_OPTION',
+ '--stop-on-error does not accept a value.'
+ )
+ }
+ stopOnError = true
+ continue
+ }
+ if (option.name === '--json') {
+ if (option.inlineValue !== undefined) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--json does not accept a value.')
+ }
+ json = true
+ continue
+ }
+ if (option.name === '--timeout') {
+ const parsed = requiredValue(option, args, index)
+ timeoutMs = parseNonNegativeInteger(parsed.value, '--timeout', 3_600_000)
+ if (timeoutMs === 0) {
+ throw new NndCliError('NND_CLI_INVALID_OPTION', '--timeout must be greater than zero.')
+ }
+ index += parsed.consumed
+ continue
+ }
+ throw new NndCliError('NND_CLI_INVALID_OPTION', `Unknown replay option: ${option.name}.`, {
+ option: option.name
+ })
+ }
+ if (source) {
+ throw new NndCliError('NND_CLI_USAGE', 'nnd replay accepts exactly one source path.')
+ }
+ source = argument
+ }
+
+ if (!source) {
+ throw new NndCliError('NND_CLI_USAGE', 'nnd replay requires a Session directory or HAR file.')
+ }
+ return {
+ command: 'replay',
+ source,
+ dryRun,
+ stopOnError,
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
+ json
+ }
+}
+
+export function parseCliArgs(args: readonly string[]): CliInvocation {
+ const [command, ...rest] = args
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
+ return { command: 'help' }
+ }
+ if (command === '--version' || command === '-v' || command === 'version') {
+ return { command: 'version' }
+ }
+ if (command === 'dev') return parseDev(rest)
+ if (command === 'doctor') return parseDoctor(rest)
+ if (command === 'replay') return parseReplay(rest)
+ throw new NndCliError('NND_CLI_USAGE', `Unknown command: ${command}.`, { command })
+}
diff --git a/packages/network-debugger/src/cli/bin.ts b/packages/network-debugger/src/cli/bin.ts
new file mode 100644
index 0000000..0451851
--- /dev/null
+++ b/packages/network-debugger/src/cli/bin.ts
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+import { runCli } from './main'
+
+void runCli(process.argv.slice(2)).then((exitCode) => {
+ process.exitCode = exitCode
+})
diff --git a/packages/network-debugger/src/cli/dev.test.ts b/packages/network-debugger/src/cli/dev.test.ts
new file mode 100644
index 0000000..6925953
--- /dev/null
+++ b/packages/network-debugger/src/cli/dev.test.ts
@@ -0,0 +1,371 @@
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import type { ChildProcess } from 'node:child_process'
+import { describe, expect, it, vi } from 'vitest'
+import type { DevtoolsTarget } from '../adapters/types'
+import type { ResolvedNndConfig } from '../config'
+import { NND_PRELOAD_CONFIG_ENV } from '../config'
+import { NND_READY_PREFIX } from '../preload'
+import {
+ InspectorUrlParser,
+ ReadyMessageParser,
+ buildDevCommand,
+ runDevCommand,
+ shouldLaunchInspector,
+ type DevCommand
+} from './dev'
+
+function config(overrides: Partial = {}): ResolvedNndConfig {
+ return {
+ mode: 'auto',
+ open: false,
+ wait: true,
+ watch: false,
+ runner: 'node',
+ inspector: { host: '127.0.0.1', port: 0 },
+ requiredCapabilities: [],
+ legacy: {},
+ ...overrides
+ }
+}
+
+function fakeChild() {
+ const child = new EventEmitter() as ChildProcess
+ Object.assign(child, {
+ stderr: new PassThrough(),
+ kill: vi.fn(() => true)
+ })
+ return child
+}
+
+const target = (url: string): DevtoolsTarget => ({
+ id: url.split('/').at(-1)!,
+ title: 'target',
+ type: 'node',
+ url: '',
+ webSocketDebuggerUrl: url,
+ discoveryUrl: 'http://127.0.0.1/json/list',
+ devtoolsFrontendUrl: `devtools://devtools/bundled/inspector.html?ws=${url.slice(5)}`
+})
+
+function command(overrides: Partial = {}): DevCommand {
+ return {
+ executable: '/node',
+ args: ['app.js'],
+ cwd: '/project',
+ env: {},
+ open: false,
+ wait: false,
+ ...overrides
+ }
+}
+
+const nativeRuntime = {
+ nodeVersion: '24.7.0',
+ inspectorAvailable: true,
+ inspectorNetwork: {
+ requestWillBeSent: vi.fn(),
+ responseReceived: vi.fn(),
+ loadingFinished: vi.fn(),
+ loadingFailed: vi.fn()
+ }
+} as const
+
+describe('dev command construction', () => {
+ it('injects native flags and the absolute preload URL without changing NODE_OPTIONS', () => {
+ const built = buildDevCommand({
+ entry: 'src/app.ts',
+ applicationArgs: ['--port', '3000'],
+ config: config({ open: true, watch: true, runner: 'tsx' }),
+ cwd: '/project',
+ env: { NODE_OPTIONS: '--trace-warnings' },
+ execPath: '/usr/bin/node',
+ preloadUrl: 'file:///package/dist/register.mjs',
+ ...nativeRuntime
+ })
+
+ expect(built).toMatchObject({
+ executable: '/usr/bin/node',
+ cwd: '/project',
+ open: true,
+ wait: true
+ })
+ expect(built.args).toEqual([
+ '--experimental-network-inspection',
+ '--inspect-wait=127.0.0.1:0',
+ '--watch',
+ '--import=file:///package/dist/register.mjs',
+ '--import=tsx',
+ 'src/app.ts',
+ '--port',
+ '3000'
+ ])
+ expect(built.env.NODE_OPTIONS).toBe('--trace-warnings')
+ expect(built.env.NODE_OPTIONS).not.toContain('experimental-network-inspection')
+ expect(JSON.parse(built.env[NND_PRELOAD_CONFIG_ENV]!)).toMatchObject({ mode: 'auto' })
+ })
+
+ it('uses --inspect for no-wait and omits all Inspector flags for forced Legacy', () => {
+ expect(
+ buildDevCommand({
+ entry: 'app.js',
+ config: config({ wait: false }),
+ preloadUrl: 'file:///register.mjs',
+ ...nativeRuntime
+ }).args
+ ).toEqual([
+ '--experimental-network-inspection',
+ '--inspect=127.0.0.1:0',
+ '--import=file:///register.mjs',
+ 'app.js'
+ ])
+
+ const legacy = buildDevCommand({
+ entry: 'app.js',
+ config: config({ mode: 'legacy', wait: true }),
+ preloadUrl: 'file:///register.mjs'
+ })
+ expect(legacy.args).toEqual(['--import=file:///register.mjs', 'app.js'])
+ expect(legacy.wait).toBe(false)
+ })
+
+ it('starts Auto directly on Legacy when the native baseline or requirements are unmet', () => {
+ const oldRuntime = buildDevCommand({
+ entry: 'app.js',
+ config: config(),
+ nodeVersion: '18.20.8',
+ preloadUrl: 'file:///register.mjs'
+ })
+ expect(oldRuntime.args).toEqual(['--import=file:///register.mjs', 'app.js'])
+ expect(oldRuntime.wait).toBe(false)
+
+ const missingCapability = buildDevCommand({
+ entry: 'app.js',
+ config: config({ requiredCapabilities: ['requestBody'] }),
+ nodeVersion: '24.16.0',
+ inspectorNetwork: {
+ requestWillBeSent: vi.fn(),
+ responseReceived: vi.fn(),
+ loadingFinished: vi.fn(),
+ loadingFailed: vi.fn(),
+ dataReceived: vi.fn()
+ },
+ preloadUrl: 'file:///register.mjs'
+ })
+ expect(missingCapability.args).toEqual(['--import=file:///register.mjs', 'app.js'])
+ expect(missingCapability.wait).toBe(false)
+ })
+
+ it('uses the shared Native Auto baseline at Node 18/20/24.6/24.7 boundaries', () => {
+ const network = {
+ requestWillBeSent: vi.fn(),
+ responseReceived: vi.fn(),
+ loadingFinished: vi.fn(),
+ loadingFailed: vi.fn(),
+ dataReceived: vi.fn()
+ }
+ for (const nodeVersion of ['18.20.8', '20.19.5', '24.6.0']) {
+ expect(shouldLaunchInspector(config(), { nodeVersion, inspectorNetwork: network })).toBe(
+ false
+ )
+ }
+ expect(
+ shouldLaunchInspector(config(), {
+ nodeVersion: '24.7.0',
+ inspectorNetwork: network,
+ inspectorAvailable: true
+ })
+ ).toBe(true)
+ expect(
+ shouldLaunchInspector(config(), {
+ nodeVersion: '24.7.0',
+ inspectorNetwork: network,
+ inspectorAvailable: false
+ })
+ ).toBe(false)
+ })
+
+ it('rejects forced Native before spawn when the runtime does not recognize the flag', () => {
+ for (const nodeVersion of ['18.20.8', '20.17.0', '21.7.3', '22.5.1']) {
+ expect(() =>
+ buildDevCommand({
+ entry: 'app.js',
+ config: config({ mode: 'native' }),
+ nodeVersion,
+ preloadUrl: 'file:///register.mjs'
+ })
+ ).toThrowError(
+ expect.objectContaining({
+ code: 'NND_CLI_NATIVE_UNSUPPORTED',
+ message: expect.stringContaining('use --mode auto/legacy')
+ })
+ )
+ }
+ })
+
+ it('injects forced Native flags at the Node 20.18 and 22.6 support boundaries', () => {
+ for (const nodeVersion of ['20.18.0', '22.6.0']) {
+ const native = buildDevCommand({
+ entry: 'app.js',
+ config: config({ mode: 'native' }),
+ nodeVersion,
+ preloadUrl: 'file:///register.mjs'
+ })
+ expect(native.args.slice(0, 2)).toEqual([
+ '--experimental-network-inspection',
+ '--inspect-wait=127.0.0.1:0'
+ ])
+ }
+ })
+})
+
+describe('stderr protocol parsers', () => {
+ it('waits for a complete Debugger listening line across chunks', () => {
+ const parser = new InspectorUrlParser()
+ expect(parser.push('Debugger listening on ws://127.0.0.1:1234/')).toEqual([])
+ expect(parser.push('abc\nFor help, see docs\n')).toEqual(['ws://127.0.0.1:1234/abc'])
+ expect(
+ parser.push(`${NND_READY_PREFIX}{"target":{"webSocketDebuggerUrl":"ws://wrong"}}\n`)
+ ).toEqual([])
+ })
+
+ it('parses split ready messages and ignores malformed data', () => {
+ const parser = new ReadyMessageParser()
+ const ready = {
+ mode: 'legacy',
+ target: target('ws://127.0.0.1:5271'),
+ capabilities: { http: true }
+ }
+ const line = `${NND_READY_PREFIX}${JSON.stringify(ready)}\n`
+ expect(parser.push(line.slice(0, 20))).toEqual([])
+ expect(parser.push(line.slice(20))).toEqual([ready])
+ expect(parser.push(`${NND_READY_PREFIX}{nope}\n`)).toEqual([])
+ })
+})
+
+describe('dev child lifecycle', () => {
+ it('forwards parent signals, maps signal exit codes, and removes listeners', async () => {
+ const child = fakeChild()
+ const signals = new EventEmitter()
+ const result = runDevCommand(command(), {
+ spawn: vi.fn(() => child),
+ stderr: { write: vi.fn(() => true) },
+ signals
+ })
+
+ signals.emit('SIGINT')
+ expect(child.kill).toHaveBeenCalledWith('SIGINT')
+ child.emit('exit', null, 'SIGINT')
+ await expect(result).resolves.toBe(130)
+ expect(signals.listenerCount('SIGINT')).toBe(0)
+ expect(signals.listenerCount('SIGTERM')).toBe(0)
+ expect(signals.listenerCount('SIGHUP')).toBe(0)
+ })
+
+ it('prints an actionable status when default wait has no automatic frontend', async () => {
+ const child = fakeChild()
+ const stderr = { write: vi.fn(() => true) }
+ const result = runDevCommand(command({ wait: true }), {
+ spawn: vi.fn(() => child),
+ stderr,
+ signals: new EventEmitter()
+ })
+ expect(stderr.write).toHaveBeenCalledWith(expect.stringContaining('NND_WAITING_FOR_FRONTEND'))
+ child.emit('exit', 0, null)
+ await expect(result).resolves.toBe(0)
+ })
+
+ it('opens the preliminary waiting target once, then a different fallback ready target', async () => {
+ const child = fakeChild()
+ const openInspector = vi.fn(async () => undefined)
+ const openTarget = vi.fn(async () => undefined)
+ const result = runDevCommand(command({ open: true, wait: true }), {
+ spawn: vi.fn(() => child),
+ stderr: { write: vi.fn(() => true) },
+ signals: new EventEmitter(),
+ openInspector,
+ openTarget
+ })
+ const nativeUrl = 'ws://127.0.0.1:1234/native'
+ child.stderr!.emit('data', `Debugger listening on ${nativeUrl}\n`)
+ child.stderr!.emit(
+ 'data',
+ `${NND_READY_PREFIX}${JSON.stringify({
+ mode: 'native',
+ target: target(nativeUrl),
+ capabilities: { http: true }
+ })}\n`
+ )
+ const legacyTarget = target('ws://127.0.0.1:5271/legacy')
+ child.stderr!.emit(
+ 'data',
+ `${NND_READY_PREFIX}${JSON.stringify({
+ mode: 'legacy',
+ target: legacyTarget,
+ capabilities: { http: true }
+ })}\n`
+ )
+
+ await Promise.resolve()
+ expect(openInspector).toHaveBeenCalledTimes(1)
+ expect(openInspector).toHaveBeenCalledWith(nativeUrl)
+ expect(openTarget).toHaveBeenCalledTimes(1)
+ expect(openTarget).toHaveBeenCalledWith(legacyTarget)
+ child.emit('exit', 0, null)
+ await expect(result).resolves.toBe(0)
+ })
+
+ it('opens only the authoritative ready target in Legacy/no-wait mode', async () => {
+ const child = fakeChild()
+ const openInspector = vi.fn(async () => undefined)
+ const openTarget = vi.fn(async () => undefined)
+ const result = runDevCommand(command({ open: true, wait: false }), {
+ spawn: vi.fn(() => child),
+ stderr: { write: vi.fn(() => true) },
+ signals: new EventEmitter(),
+ openInspector,
+ openTarget
+ })
+ const readyTarget = target('ws://127.0.0.1:5271/legacy')
+ child.stderr!.emit('data', `Debugger listening on ws://127.0.0.1:1234/native\n`)
+ child.stderr!.emit(
+ 'data',
+ `${NND_READY_PREFIX}${JSON.stringify({
+ mode: 'legacy',
+ target: readyTarget,
+ capabilities: { http: true }
+ })}\n`
+ )
+ await Promise.resolve()
+
+ expect(openInspector).not.toHaveBeenCalled()
+ expect(openTarget).toHaveBeenCalledWith(readyTarget)
+ child.emit('exit', 0, null)
+ await expect(result).resolves.toBe(0)
+ })
+
+ it('returns one and terminates the child when frontend launch fails', async () => {
+ const child = fakeChild()
+ const result = runDevCommand(command({ open: true, wait: false }), {
+ spawn: vi.fn(() => child),
+ stderr: { write: vi.fn(() => true) },
+ signals: new EventEmitter(),
+ openTarget: vi.fn(async () => {
+ throw new Error('browser failed')
+ })
+ })
+ child.stderr!.emit(
+ 'data',
+ `${NND_READY_PREFIX}${JSON.stringify({
+ mode: 'native',
+ target: target('ws://127.0.0.1:1234/native'),
+ capabilities: { http: true }
+ })}\n`
+ )
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM')
+ child.emit('exit', null, 'SIGTERM')
+ await expect(result).resolves.toBe(1)
+ })
+})
diff --git a/packages/network-debugger/src/cli/dev.ts b/packages/network-debugger/src/cli/dev.ts
new file mode 100644
index 0000000..dff6c8e
--- /dev/null
+++ b/packages/network-debugger/src/cli/dev.ts
@@ -0,0 +1,328 @@
+import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
+import * as nodeInspector from 'node:inspector'
+import type { Writable } from 'node:stream'
+import { discoverInspectorTarget } from '../adapters/node-native'
+import {
+ NATIVE_NETWORK_INSPECTION_FLAG,
+ getNativeCapabilities,
+ hasRequiredNativeMethods,
+ isNativeAutoBaseline,
+ parseNodeVersion,
+ supportsNativeNetworkInspection,
+ type NativeNetworkApi
+} from '../adapters/node-native/capability'
+import { NND_PRELOAD_CONFIG_ENV, serializePreloadConfig, type ResolvedNndConfig } from '../config'
+import type { DevtoolsTarget } from '../adapters/types'
+import { NND_PRELOAD_REPORT_ENV, NND_READY_PREFIX } from '../preload'
+import { openDevtoolsTarget } from '../target/frontend-launcher'
+import { NndCliError, formatCliError } from './errors'
+
+export interface DevCommand {
+ executable: string
+ args: readonly string[]
+ cwd: string
+ env: NodeJS.ProcessEnv
+ open: boolean
+ wait: boolean
+}
+
+export interface BuildDevCommandOptions {
+ entry: string
+ applicationArgs?: readonly string[]
+ config: ResolvedNndConfig
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ execPath?: string
+ preloadUrl?: string
+ nodeVersion?: string
+ inspectorNetwork?: NativeNetworkApi
+ inspectorAvailable?: boolean
+}
+
+export function defaultPreloadUrl(moduleUrl: string = import.meta.url): string {
+ return new URL('./register.mjs', moduleUrl).href
+}
+
+export function shouldLaunchInspector(
+ config: ResolvedNndConfig,
+ options: Pick<
+ BuildDevCommandOptions,
+ 'nodeVersion' | 'inspectorNetwork' | 'inspectorAvailable'
+ > = {}
+): boolean {
+ if (config.mode === 'legacy') return false
+ if (config.mode === 'native') return true
+
+ const version = parseNodeVersion(options.nodeVersion ?? process.versions.node)
+ if (!isNativeAutoBaseline(version)) return false
+ const inspectorAvailable = options.inspectorAvailable ?? process.features?.inspector !== false
+ if (!inspectorAvailable) return false
+ const network =
+ options.inspectorNetwork ?? (nodeInspector as unknown as { Network?: NativeNetworkApi }).Network
+ if (!hasRequiredNativeMethods(network)) return false
+ const capabilities = getNativeCapabilities(version, network)
+ return config.requiredCapabilities.every((capability) => capabilities[capability])
+}
+
+export function buildDevCommand(options: BuildDevCommandOptions): DevCommand {
+ const { config } = options
+ const nodeVersionText = options.nodeVersion ?? process.versions.node
+ if (
+ config.mode === 'native' &&
+ !supportsNativeNetworkInspection(parseNodeVersion(nodeVersionText))
+ ) {
+ throw new NndCliError(
+ 'NND_CLI_NATIVE_UNSUPPORTED',
+ `Node.js ${nodeVersionText} does not support ${NATIVE_NETWORK_INSPECTION_FLAG}. Upgrade to Node.js 20.18+, 22.6+, or a newer release, or use --mode auto/legacy.`,
+ {
+ nodeVersion: nodeVersionText,
+ flag: NATIVE_NETWORK_INSPECTION_FLAG,
+ supportedBaselines: ['20.18+', '22.6+', '23+']
+ }
+ )
+ }
+ const inspectorAddress = `${config.inspector.host}:${config.inspector.port}`
+ const usesInspector = shouldLaunchInspector(config, options)
+ const args = usesInspector
+ ? [
+ NATIVE_NETWORK_INSPECTION_FLAG,
+ `${config.wait ? '--inspect-wait' : '--inspect'}=${inspectorAddress}`
+ ]
+ : []
+
+ if (config.watch) args.push('--watch')
+ args.push(`--import=${options.preloadUrl ?? defaultPreloadUrl()}`)
+ if (config.runner === 'tsx') args.push('--import=tsx')
+ args.push(options.entry, ...(options.applicationArgs ?? []))
+
+ return {
+ executable: options.execPath ?? process.execPath,
+ args,
+ cwd: options.cwd ?? process.cwd(),
+ env: {
+ ...(options.env ?? process.env),
+ [NND_PRELOAD_CONFIG_ENV]: serializePreloadConfig(config),
+ [NND_PRELOAD_REPORT_ENV]: '1'
+ },
+ open: config.open,
+ wait: usesInspector && config.wait
+ }
+}
+
+type SupportedSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP'
+
+const SIGNAL_EXIT_CODES: Readonly> = {
+ SIGINT: 130,
+ SIGTERM: 143,
+ SIGHUP: 129
+}
+
+export interface SignalSource {
+ on(event: SupportedSignal, listener: () => void): unknown
+ off(event: SupportedSignal, listener: () => void): unknown
+}
+
+export interface RunDevDependencies {
+ spawn?: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess
+ stderr?: Pick
+ signals?: SignalSource
+ openInspector?: (inspectorUrl: string) => Promise
+ openTarget?: (target: DevtoolsTarget) => Promise
+}
+
+async function defaultOpenInspector(inspectorUrl: string): Promise {
+ const target = await discoverInspectorTarget(inspectorUrl)
+ await openDevtoolsTarget(target)
+}
+
+/** Extracts complete Inspector WebSocket URLs even when stderr arrives in chunks. */
+export class InspectorUrlParser {
+ private pending = ''
+ private emitted = new Set()
+
+ push(chunk: string): readonly string[] {
+ this.pending = `${this.pending}${chunk}`.slice(-8192)
+ const lastNewline = this.pending.lastIndexOf('\n')
+ if (lastNewline < 0) return []
+ const complete = this.pending.slice(0, lastNewline + 1)
+ this.pending = this.pending.slice(lastNewline + 1)
+ const found = complete
+ .split(/\r?\n/)
+ .filter((line) => line.includes('Debugger listening on '))
+ .flatMap((line) => line.match(/ws:\/\/[^\s]+/g) ?? [])
+ const fresh = found
+ .map((url) => url.replace(/[),.;]+$/, ''))
+ .filter((url) => {
+ if (this.emitted.has(url)) return false
+ this.emitted.add(url)
+ return true
+ })
+ return fresh
+ }
+}
+
+export interface NndReadyMessage {
+ mode: 'native' | 'legacy'
+ target: DevtoolsTarget
+ capabilities: Readonly>
+ fallbackReason?: unknown
+}
+
+export class ReadyMessageParser {
+ private pending = ''
+
+ push(chunk: string): readonly NndReadyMessage[] {
+ this.pending = `${this.pending}${chunk}`.slice(-65_536)
+ const lastNewline = this.pending.lastIndexOf('\n')
+ if (lastNewline < 0) return []
+ const lines = this.pending.slice(0, lastNewline + 1).split(/\r?\n/)
+ this.pending = this.pending.slice(lastNewline + 1)
+ const messages: NndReadyMessage[] = []
+ for (const line of lines) {
+ const index = line.indexOf(NND_READY_PREFIX)
+ if (index < 0) continue
+ try {
+ const value = JSON.parse(line.slice(index + NND_READY_PREFIX.length)) as NndReadyMessage
+ if (
+ (value.mode === 'native' || value.mode === 'legacy') &&
+ value.target &&
+ typeof value.target.webSocketDebuggerUrl === 'string' &&
+ value.capabilities &&
+ typeof value.capabilities === 'object'
+ ) {
+ messages.push(value)
+ }
+ } catch {
+ // The original stderr line remains visible; malformed status data must
+ // not crash or hide the child process.
+ }
+ }
+ return messages
+ }
+}
+
+export function runDevCommand(
+ command: DevCommand,
+ dependencies: RunDevDependencies = {}
+): Promise {
+ const spawn = dependencies.spawn ?? nodeSpawn
+ const stderr = dependencies.stderr ?? process.stderr
+ const signals = dependencies.signals ?? process
+ const openInspector = dependencies.openInspector ?? defaultOpenInspector
+ const openTarget = dependencies.openTarget ?? openDevtoolsTarget
+ if (command.wait && !command.open) {
+ stderr.write(
+ '[nnd:NND_WAITING_FOR_FRONTEND] Application entry is paused; attach to the Inspector URL or rerun with --open/--no-wait.\n'
+ )
+ }
+ let child: ChildProcess
+
+ try {
+ child = spawn(command.executable, command.args, {
+ cwd: command.cwd,
+ env: command.env,
+ stdio: ['inherit', 'inherit', 'pipe']
+ })
+ } catch (error) {
+ return Promise.reject(
+ new NndCliError(
+ 'NND_CLI_SPAWN_FAILED',
+ `Unable to start ${command.executable}.`,
+ { executable: command.executable },
+ error
+ )
+ )
+ }
+
+ return new Promise((resolvePromise, rejectPromise) => {
+ const urlParser = new InspectorUrlParser()
+ const readyParser = new ReadyMessageParser()
+ let requestedSignal: SupportedSignal | undefined
+ let frontendError = false
+ let settled = false
+ const openedTargets = new Set()
+
+ const signalListeners = new Map void>()
+ const cleanup = () => {
+ for (const [signal, listener] of signalListeners) signals.off(signal, listener)
+ signalListeners.clear()
+ }
+ const settle = (action: () => void) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ action()
+ }
+
+ for (const signal of Object.keys(SIGNAL_EXIT_CODES) as SupportedSignal[]) {
+ const listener = () => {
+ requestedSignal = signal
+ child.kill(signal)
+ }
+ signalListeners.set(signal, listener)
+ signals.on(signal, listener)
+ }
+
+ child.stderr?.on('data', (chunk: Buffer | string) => {
+ const text = chunk.toString()
+ stderr.write(text)
+ const inspectorUrls = urlParser.push(text)
+ const readyMessages = readyParser.push(text)
+ if (!command.open) return
+
+ const open = (key: string, action: () => Promise) => {
+ if (openedTargets.has(key)) return
+ openedTargets.add(key)
+ void action().catch((error) => {
+ frontendError = true
+ stderr.write(
+ `${formatCliError(
+ new NndCliError(
+ 'NND_CLI_FRONTEND_OPEN_FAILED',
+ `Unable to open DevTools for ${key}.`,
+ { target: key },
+ error
+ )
+ )}\n`
+ )
+ child.kill('SIGTERM')
+ })
+ }
+
+ // A waiting Native/Auto process must first be attached so preload can
+ // execute. No-wait and Legacy modes wait for the authoritative ready target.
+ if (command.wait) {
+ for (const inspectorUrl of inspectorUrls) {
+ open(inspectorUrl, () => openInspector(inspectorUrl))
+ }
+ }
+ for (const ready of readyMessages) {
+ open(ready.target.webSocketDebuggerUrl, () => openTarget(ready.target))
+ }
+ })
+
+ child.once('error', (error) => {
+ settle(() =>
+ rejectPromise(
+ new NndCliError(
+ 'NND_CLI_SPAWN_FAILED',
+ `Unable to start ${command.executable}: ${error.message}`,
+ { executable: command.executable },
+ error
+ )
+ )
+ )
+ })
+ child.once('exit', (code, signal) => {
+ settle(() => {
+ if (frontendError) return resolvePromise(1)
+ if (typeof code === 'number') return resolvePromise(code)
+ if (requestedSignal) return resolvePromise(SIGNAL_EXIT_CODES[requestedSignal])
+ if (signal && signal in SIGNAL_EXIT_CODES) {
+ return resolvePromise(SIGNAL_EXIT_CODES[signal as SupportedSignal])
+ }
+ return resolvePromise(1)
+ })
+ })
+ })
+}
diff --git a/packages/network-debugger/src/cli/errors.ts b/packages/network-debugger/src/cli/errors.ts
new file mode 100644
index 0000000..60d77de
--- /dev/null
+++ b/packages/network-debugger/src/cli/errors.ts
@@ -0,0 +1,27 @@
+export type NndCliErrorCode =
+ | 'NND_CLI_USAGE'
+ | 'NND_CLI_INVALID_OPTION'
+ | 'NND_CLI_NATIVE_UNSUPPORTED'
+ | 'NND_CLI_SPAWN_FAILED'
+ | 'NND_CLI_FRONTEND_OPEN_FAILED'
+
+export class NndCliError extends Error {
+ constructor(
+ readonly code: NndCliErrorCode,
+ message: string,
+ readonly details: Readonly> = {},
+ readonly cause?: unknown
+ ) {
+ super(message)
+ this.name = 'NndCliError'
+ }
+}
+
+export function formatCliError(error: unknown): string {
+ const code =
+ typeof error === 'object' && error !== null && 'code' in error
+ ? String((error as { code: unknown }).code)
+ : 'NND_CLI_FAILED'
+ const message = error instanceof Error ? error.message : String(error)
+ return `[nnd:${code}] ${message}`
+}
diff --git a/packages/network-debugger/src/cli/help.ts b/packages/network-debugger/src/cli/help.ts
new file mode 100644
index 0000000..157558a
--- /dev/null
+++ b/packages/network-debugger/src/cli/help.ts
@@ -0,0 +1,31 @@
+export const CLI_HELP = `Node Network Devtools
+
+Usage:
+ nnd dev [options] [-- args...]
+ nnd doctor [--json] [--probe-wait ]
+ nnd replay [--dry-run] [--json] [--timeout ]
+
+Dev options:
+ --open Open DevTools when the Inspector target appears
+ --no-wait Start the application immediately (default: wait)
+ --watch Restart the application when files change
+ --runner Select the application runner (default: node)
+ --mode
+ --config Use an explicit nnd.config.mjs/cjs/json file
+ --inspect-host Inspector bind host (default: 127.0.0.1)
+ --inspect-port Inspector port; 0 asks the OS to choose
+ --require Require an adapter capability; may be repeated
+
+Replay options:
+ --dry-run Validate and print the request plan without I/O
+ --stop-on-error Stop after the first HTTP or transport failure
+ --timeout Per-request timeout (default: 30000)
+ --json Print the complete machine-readable report
+
+Configuration precedence:
+ explicit CLI > NND_* environment > config file > defaults
+
+By default, Native/Auto targets pause before the application entry runs. Attach
+a debugger to the printed Inspector URL, pass --open to attach automatically,
+or pass --no-wait to run immediately. Forced Legacy mode never starts Inspector.
+`
diff --git a/packages/network-debugger/src/cli/index.ts b/packages/network-debugger/src/cli/index.ts
new file mode 100644
index 0000000..1004dcd
--- /dev/null
+++ b/packages/network-debugger/src/cli/index.ts
@@ -0,0 +1,5 @@
+export * from './args'
+export * from './dev'
+export * from './errors'
+export * from './help'
+export * from './main'
diff --git a/packages/network-debugger/src/cli/main.test.ts b/packages/network-debugger/src/cli/main.test.ts
new file mode 100644
index 0000000..d3d1e0e
--- /dev/null
+++ b/packages/network-debugger/src/cli/main.test.ts
@@ -0,0 +1,177 @@
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import type { ChildProcess } from 'node:child_process'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { runCli } from './main'
+
+const temporaryDirectories: string[] = []
+
+afterEach(() => {
+ for (const directory of temporaryDirectories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true })
+ }
+})
+
+function output() {
+ let value = ''
+ return {
+ stream: { write: vi.fn((chunk: unknown) => ((value += String(chunk)), true)) },
+ value: () => value
+ }
+}
+
+describe('runCli', () => {
+ it('prints help and package version without spawning', async () => {
+ const stdout = output()
+ await expect(runCli([], { stdout: stdout.stream, stderr: output().stream })).resolves.toBe(0)
+ expect(stdout.value()).toContain('nnd dev [options] ')
+
+ const version = output()
+ await expect(
+ runCli(['--version'], {
+ stdout: version.stream,
+ stderr: output().stream,
+ packageVersion: '9.8.7'
+ })
+ ).resolves.toBe(0)
+ expect(version.value()).toBe('9.8.7\n')
+ })
+
+ it('prints a machine-readable doctor report', async () => {
+ const stdout = output()
+ const exitCode = await runCli(['doctor', '--json', '--mode', 'legacy'], {
+ stdout: stdout.stream,
+ stderr: output().stream,
+ cwd: process.cwd(),
+ env: {},
+ packageVersion: '1.0.30'
+ })
+ const report = JSON.parse(stdout.value()) as {
+ schemaVersion: number
+ ok: boolean
+ selection: { selected: string }
+ }
+ expect(exitCode).toBe(0)
+ expect(report).toMatchObject({
+ schemaVersion: 1,
+ ok: true,
+ selection: { selected: 'legacy' }
+ })
+ })
+
+ it('prints a machine-readable replay dry-run without network I/O', async () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'nnd-cli-replay-'))
+ temporaryDirectories.push(cwd)
+ const harPath = join(cwd, 'fixture.har')
+ writeFileSync(
+ harPath,
+ JSON.stringify({
+ log: {
+ version: '1.2',
+ creator: { name: 'fixture', version: '1' },
+ pages: [],
+ entries: [
+ {
+ request: {
+ method: 'GET',
+ url: 'http://127.0.0.1:9/never-opened',
+ headers: [],
+ queryString: [],
+ cookies: [],
+ headersSize: -1,
+ bodySize: 0
+ },
+ response: {},
+ cache: {},
+ timings: {},
+ _requestId: 'dry-run'
+ }
+ ]
+ }
+ })
+ )
+ const stdout = output()
+
+ await expect(
+ runCli(['replay', '--dry-run', '--json', 'fixture.har'], {
+ cwd,
+ stdout: stdout.stream,
+ stderr: output().stream
+ })
+ ).resolves.toBe(0)
+ expect(JSON.parse(stdout.value())).toMatchObject({
+ dryRun: true,
+ succeeded: 1,
+ failed: 0,
+ requests: [{ requestId: 'dry-run', method: 'GET' }]
+ })
+ })
+
+ it('resolves config and launches the built dev command', async () => {
+ const child = new EventEmitter() as ChildProcess
+ Object.assign(child, { stderr: new PassThrough(), kill: vi.fn(() => true) })
+ const spawn = vi.fn(() => {
+ queueMicrotask(() => child.emit('exit', 7, null))
+ return child
+ })
+ const stderr = output()
+ const exitCode = await runCli(
+ ['dev', '--mode', 'native', '--no-wait', '--runner', 'tsx', 'app.ts', '--', '--port', '3'],
+ {
+ cwd: '/project',
+ env: {},
+ execPath: '/node',
+ nodeVersion: '24.7.0',
+ preloadUrl: 'file:///dist/register.mjs',
+ spawn,
+ signals: new EventEmitter(),
+ stdout: output().stream,
+ stderr: stderr.stream
+ }
+ )
+
+ expect(exitCode).toBe(7)
+ expect(spawn).toHaveBeenCalledWith(
+ '/node',
+ [
+ '--experimental-network-inspection',
+ '--inspect=127.0.0.1:0',
+ '--import=file:///dist/register.mjs',
+ '--import=tsx',
+ 'app.ts',
+ '--port',
+ '3'
+ ],
+ expect.objectContaining({ cwd: '/project' })
+ )
+ })
+
+ it('formats parser failures with stable codes', async () => {
+ const stderr = output()
+ await expect(runCli(['wat'], { stderr: stderr.stream, stdout: output().stream })).resolves.toBe(
+ 1
+ )
+ expect(stderr.value()).toContain('[nnd:NND_CLI_USAGE]')
+ })
+
+ it('does not spawn forced Native on a runtime that cannot parse its flag', async () => {
+ const stderr = output()
+ const spawn = vi.fn()
+ const exitCode = await runCli(['dev', '--mode', 'native', 'app.js'], {
+ cwd: '/project',
+ env: {},
+ nodeVersion: '18.20.8',
+ spawn,
+ stdout: output().stream,
+ stderr: stderr.stream
+ })
+
+ expect(exitCode).toBe(1)
+ expect(spawn).not.toHaveBeenCalled()
+ expect(stderr.value()).toContain('[nnd:NND_CLI_NATIVE_UNSUPPORTED]')
+ expect(stderr.value()).toContain('Node.js 18.20.8')
+ })
+})
diff --git a/packages/network-debugger/src/cli/main.ts b/packages/network-debugger/src/cli/main.ts
new file mode 100644
index 0000000..417190a
--- /dev/null
+++ b/packages/network-debugger/src/cli/main.ts
@@ -0,0 +1,100 @@
+import type { Writable } from 'node:stream'
+import { resolve } from 'node:path'
+import { resolveConfig } from '../config'
+import { detectPackageVersion, formatDoctorReport, runDoctor } from '../diagnostics'
+import { replay } from '../replay'
+import { parseCliArgs } from './args'
+import { buildDevCommand, runDevCommand, type RunDevDependencies } from './dev'
+import { formatCliError } from './errors'
+import { CLI_HELP } from './help'
+
+export interface RunCliOptions extends RunDevDependencies {
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ execPath?: string
+ /** Dependency override used by compatibility checks and tests. */
+ nodeVersion?: string
+ preloadUrl?: string
+ stdout?: Pick
+ packageVersion?: string
+}
+
+export async function runCli(
+ args: readonly string[],
+ options: RunCliOptions = {}
+): Promise {
+ const stdout = options.stdout ?? process.stdout
+ const stderr = options.stderr ?? process.stderr
+
+ try {
+ const invocation = parseCliArgs(args)
+ if (invocation.command === 'help') {
+ stdout.write(CLI_HELP)
+ return 0
+ }
+ if (invocation.command === 'version') {
+ stdout.write(`${options.packageVersion ?? detectPackageVersion()}\n`)
+ return 0
+ }
+ if (invocation.command === 'doctor') {
+ const report = await runDoctor({
+ cwd: options.cwd,
+ env: options.env,
+ config: invocation.config,
+ configFile: invocation.configFile,
+ probeWaitMs: invocation.probeWaitMs,
+ packageVersion: options.packageVersion
+ })
+ stdout.write(formatDoctorReport(report, invocation.json))
+ return report.ok ? 0 : 1
+ }
+ if (invocation.command === 'replay') {
+ const report = await replay(resolve(options.cwd ?? process.cwd(), invocation.source), {
+ dryRun: invocation.dryRun,
+ stopOnError: invocation.stopOnError,
+ timeoutMs: invocation.timeoutMs
+ })
+ if (invocation.json) {
+ stdout.write(`${JSON.stringify(report, null, 2)}\n`)
+ } else {
+ stdout.write(
+ `${invocation.dryRun ? 'Planned' : 'Replayed'} ${report.results.length} request(s): ${report.succeeded} succeeded, ${report.failed} failed.\n`
+ )
+ for (const result of report.results) {
+ const outcome = result.ok
+ ? (result.status ?? 'ready')
+ : (result.error ?? result.status ?? 'failed')
+ stdout.write(`${result.request.method} ${result.request.url} -> ${outcome}\n`)
+ }
+ }
+ return report.failed === 0 ? 0 : 1
+ }
+
+ const resolution = await resolveConfig({
+ cwd: options.cwd,
+ env: options.env,
+ cli: invocation.config,
+ configFile: invocation.configFile
+ })
+ const command = buildDevCommand({
+ entry: invocation.entry,
+ applicationArgs: invocation.applicationArgs,
+ config: resolution.config,
+ cwd: options.cwd,
+ env: options.env,
+ execPath: options.execPath,
+ nodeVersion: options.nodeVersion,
+ preloadUrl: options.preloadUrl
+ })
+ return await runDevCommand(command, {
+ spawn: options.spawn,
+ stderr,
+ signals: options.signals,
+ openInspector: options.openInspector,
+ openTarget: options.openTarget
+ })
+ } catch (error) {
+ stderr.write(`${formatCliError(error)}\n`)
+ return 1
+ }
+}
diff --git a/packages/network-debugger/src/common.test.ts b/packages/network-debugger/src/common.test.ts
index 80a8137..76ac349 100644
--- a/packages/network-debugger/src/common.test.ts
+++ b/packages/network-debugger/src/common.test.ts
@@ -1,21 +1,5 @@
import { vi, describe, beforeEach, test, expect } from 'vitest'
-import {
- RequestDetail,
- PORT,
- SERVER_PORT,
- REMOTE_DEBUGGER_PORT,
- IS_DEV_MODE,
- READY_MESSAGE,
- NETWORK_CONTEXT_KEY,
- WS_PROTOCOL,
- CONTEXT_KEY_PORT,
- CONTEXT_KEY_SERVER_PORT,
- CONTEXT_KEY_AUTO_OPEN_DEVTOOL,
- CONTEXT_KEY_INTERCEPT_NORMAL,
- CONTEXT_KEY_INTERCEPT_FETCH,
- CONTEXT_KEY_INTERCEPT_UNDICI_FETCH,
- CONTEXT_KEY_HASH
-} from './common'
+import { RequestDetail } from './common'
describe('RequestDetail', () => {
beforeEach(() => {
@@ -254,164 +238,22 @@ describe('RequestDetail', () => {
})
})
- describe('isHiden', () => {
- test('should return true for ws://127.0.0.1/ websocket connections', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = 'ws://127.0.0.1/'
-
- expect(requestDetail.isHiden()).toBe(true)
- })
-
- test('should return true for http://127.0.0.1/ websocket connections', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = 'http://127.0.0.1/'
-
- expect(requestDetail.isHiden()).toBe(true)
- })
-
- test('should return false for non-127.0.0.1 websocket connections', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = 'ws://example.com/'
-
- expect(requestDetail.isHiden()).toBe(false)
- })
-
- test('should return false for non-websocket 127.0.0.1 connections', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { 'Content-Type': 'application/json' }
- requestDetail.url = 'http://127.0.0.1/'
-
- expect(requestDetail.isHiden()).toBe(false)
- })
-
- test('should return false for websocket with different 127.0.0.1 path', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = 'ws://127.0.0.1/api'
-
- expect(requestDetail.isHiden()).toBe(false)
- })
-
- test('should return false for websocket with 127.0.0.1 and port', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = 'ws://127.0.0.1:8080/'
-
- expect(requestDetail.isHiden()).toBe(false)
- })
-
- test('should return false when url is undefined', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = undefined
-
- expect(requestDetail.isHiden()).toBe(false)
- })
-
- test('should return false for wss://127.0.0.1/ (secure websocket)', () => {
- const requestDetail = new RequestDetail()
- requestDetail.requestHeaders = { Upgrade: 'websocket' }
- requestDetail.url = 'wss://127.0.0.1/'
-
- expect(requestDetail.isHiden()).toBe(false)
- })
- })
-
describe('default property values', () => {
- test('should have undefined optional properties by default', () => {
+ test('initializes mutable header/info containers and leaves scalar fields undefined', () => {
const requestDetail = new RequestDetail()
expect(requestDetail.url).toBeUndefined()
expect(requestDetail.method).toBeUndefined()
expect(requestDetail.cookies).toBeUndefined()
- expect(requestDetail.requestHeaders).toBeUndefined()
+ expect(requestDetail.requestHeaders).toEqual({})
expect(requestDetail.requestData).toBeUndefined()
expect(requestDetail.responseData).toBeUndefined()
expect(requestDetail.responseStatusCode).toBeUndefined()
- expect(requestDetail.responseHeaders).toBeUndefined()
+ expect(requestDetail.responseHeaders).toEqual({})
+ expect(requestDetail.responseInfo).toEqual({})
expect(requestDetail.requestStartTime).toBeUndefined()
expect(requestDetail.requestEndTime).toBeUndefined()
expect(requestDetail.initiator).toBeUndefined()
})
})
})
-
-describe('常量导出', () => {
- describe('端口常量', () => {
- test('PORT 应该是数字类型', () => {
- expect(typeof PORT).toBe('number')
- })
-
- test('SERVER_PORT 应该是数字类型', () => {
- expect(typeof SERVER_PORT).toBe('number')
- })
-
- test('REMOTE_DEBUGGER_PORT 应该是数字类型', () => {
- expect(typeof REMOTE_DEBUGGER_PORT).toBe('number')
- })
-
- test('默认端口值应该正确', () => {
- // 如果没有设置环境变量,应该使用默认值
- if (!process.env.NETWORK_PORT) {
- expect(PORT).toBe(5270)
- }
- if (!process.env.NETWORK_SERVER_PORT) {
- expect(SERVER_PORT).toBe(5271)
- }
- if (!process.env.REMOTE_DEBUGGER_PORT) {
- expect(REMOTE_DEBUGGER_PORT).toBe(9333)
- }
- })
- })
-
- describe('模式常量', () => {
- test('IS_DEV_MODE 应该是布尔类型', () => {
- expect(typeof IS_DEV_MODE).toBe('boolean')
- })
-
- test('READY_MESSAGE 应该是字符串 "ready"', () => {
- expect(READY_MESSAGE).toBe('ready')
- })
- })
-
- describe('上下文键常量', () => {
- test('NETWORK_CONTEXT_KEY 应该正确', () => {
- expect(NETWORK_CONTEXT_KEY).toBe('x-network-context')
- })
-
- test('WS_PROTOCOL 应该正确', () => {
- expect(WS_PROTOCOL).toBe('ws')
- })
-
- test('CONTEXT_KEY_PORT 应该正确', () => {
- expect(CONTEXT_KEY_PORT).toBe('x-network-context-port')
- })
-
- test('CONTEXT_KEY_SERVER_PORT 应该正确', () => {
- expect(CONTEXT_KEY_SERVER_PORT).toBe('x-network-context-server-port')
- })
-
- test('CONTEXT_KEY_AUTO_OPEN_DEVTOOL 应该正确', () => {
- expect(CONTEXT_KEY_AUTO_OPEN_DEVTOOL).toBe('x-network-context-auto-open-devtools')
- })
-
- test('CONTEXT_KEY_INTERCEPT_NORMAL 应该正确', () => {
- expect(CONTEXT_KEY_INTERCEPT_NORMAL).toBe('x-network-context-intercept-normal')
- })
-
- test('CONTEXT_KEY_INTERCEPT_FETCH 应该正确', () => {
- expect(CONTEXT_KEY_INTERCEPT_FETCH).toBe('x-network-context-intercept-fetch')
- })
-
- test('CONTEXT_KEY_INTERCEPT_UNDICI_FETCH 应该正确', () => {
- expect(CONTEXT_KEY_INTERCEPT_UNDICI_FETCH).toBe('x-network-context-intercept-undici-fetch')
- })
-
- test('CONTEXT_KEY_HASH 应该正确', () => {
- expect(CONTEXT_KEY_HASH).toBe('x-network-context-hash')
- })
- })
-})
diff --git a/packages/network-debugger/src/common.ts b/packages/network-debugger/src/common.ts
index 86889d4..d07c285 100644
--- a/packages/network-debugger/src/common.ts
+++ b/packages/network-debugger/src/common.ts
@@ -2,6 +2,8 @@ import { fileURLToPath } from 'url'
import { getStackFrames, initiatorStackPipe } from './utils/stack'
import { dirname } from 'path'
import { generateUUID } from './utils'
+import type { AdapterMode, InspectorTargetOptions, NetworkCapability } from './adapters/types'
+import type { LegacyMockRule } from './mock'
export interface CDPCallFrame {
columnNumber: number
@@ -21,6 +23,8 @@ export class RequestDetail {
} else {
this.id = generateUUID()
this.responseInfo = {}
+ this.requestHeaders = {}
+ this.responseHeaders = {}
}
}
@@ -46,10 +50,6 @@ export class RequestDetail {
}
}
- isHiden() {
- return this.isWebSocket() && ['http://127.0.0.1/', 'ws://127.0.0.1/'].includes(this.url!)
- }
-
isWebSocket() {
return (
this.requestHeaders?.['Upgrade'] === 'websocket' ||
@@ -66,6 +66,7 @@ export class RequestDetail {
responseData: any
responseStatusCode?: number
+ responseStatusText?: string
responseHeaders: any
responseInfo: Partial<{
encodedDataLength: number
@@ -82,137 +83,83 @@ export class RequestDetail {
}
}
}
-export const PORT = Number(process.env.NETWORK_PORT || 5270)
-export const SERVER_PORT = Number(process.env.NETWORK_SERVER_PORT || 5271)
-export const REMOTE_DEBUGGER_PORT = Number(process.env.REMOTE_DEBUGGER_PORT || 9333)
-export const IS_DEV_MODE = process.env.NETWORK_DEBUG_MODE === 'true'
-export const READY_MESSAGE = 'ready'
-
export const __filename = fileURLToPath(import.meta.url)
export const __dirname = dirname(__filename)
+export interface InterceptOptions {
+ /** Whether to intercept the global Fetch implementation. */
+ fetch?: boolean
+ /** Whether to intercept `http.request` and `https.request`. */
+ normal?: boolean
+ /** Optional interception for the separately installed `undici` package. */
+ undici?:
+ | false
+ | {
+ fetch?: false | true | Record
+ normal?: false | true | Record
+ }
+}
+
export interface RegisterOptions {
- /**
- * @description Main Process Port
- * @default 5270
- */
- port?: number
- /**
- * @description CDP Server Port, used for Devtool
- * @link devtools://devtools/bundled/inspector.html?ws=127.0.0.1:${serverPort}
- * @default 5271
- */
- serverPort?: number
+ /** Select the complete capture/backend implementation. Defaults to `auto`. */
+ mode?: AdapterMode
- /**
- * @description Whether to automatically open Devtool
- * @default true
- */
- autoOpenDevtool?: boolean
+ /** @deprecated Use `mode`. */
+ adapter?: AdapterMode
- /**
- * @description Options for intercepting different types of requests.
- * If a property is set to `false`, that specific type of request will not be intercepted.
- * By default, all are intercepted if not explicitly set.
- */
- intercept?: {
- /**
- * @description Whether to intercept `fetch` requests.
- * @default true
- */
- fetch?: boolean
- /**
- * @description Whether to intercept `http/https` requests.
- * @default true
- */
- normal?: boolean
- /**
- * @description Options for intercepting `undici` requests.
- * Set to `false` to disable all undici interception.
- * Otherwise, configure specific undici interception options.
- * @default false
- */
- undici?:
- | false
- | {
- /**
- * @description Whether to intercept `undici`'s `fetch` requests.
- * @default false
- */
- fetch?: false | {}
- /**
- * @description Whether to intercept `undici`'s normal requests.
- * @default false
- */
- normal?: false | {}
- }
+ /** Capabilities that the selected backend must provide. */
+ requiredCapabilities?: readonly NetworkCapability[]
+
+ /** @deprecated Use `requiredCapabilities`. */
+ requiredFeatures?: readonly NetworkCapability[]
+
+ /** Settings used when a Node Inspector target must be created. */
+ inspector?: InspectorTargetOptions
+
+ /** Frontend behavior is opt-in and independent from target ownership. */
+ devtools?: {
+ open?: boolean
}
-}
-export const NETWORK_CONTEXT_KEY = 'x-network-context'
-export const WS_PROTOCOL = 'ws'
-
-export const CONTEXT_KEY_PORT = 'x-network-context-port'
-export const CONTEXT_KEY_SERVER_PORT = 'x-network-context-server-port'
-export const CONTEXT_KEY_AUTO_OPEN_DEVTOOL = 'x-network-context-auto-open-devtools'
-export const CONTEXT_KEY_INTERCEPT_NORMAL = 'x-network-context-intercept-normal'
-export const CONTEXT_KEY_INTERCEPT_FETCH = 'x-network-context-intercept-fetch'
-export const CONTEXT_KEY_INTERCEPT_UNDICI_FETCH = 'x-network-context-intercept-undici-fetch'
-export const CONTEXT_KEY_HASH = 'x-network-context-hash'
-
-export interface ConnectOptions {
- /**
- * @description Main Process Port
- * @default 5270
- */
- port?: number
-}
-export interface UnregisterOptions {
- /**
- * @description Main Process Port
- * @default 5270
- */
- port?: number
-}
+ /** Persist backend-neutral CDP events and response bodies for later export/replay. */
+ session?: {
+ /** Exact output directory. It must not already contain Session artifacts. */
+ directory: string
+ bodyCommandTimeoutMs?: number
+ /** Export HAR during disposal. `true` writes `/session.har`. */
+ har?: boolean | string
+ }
-export interface SendMessageOptions {
- /**
- * @description Main Process Port
- * @default 5270
- */
- port?: number
-}
-export type RequestPipe = (req: RequestDetail) => RequestDetail | null
+ /** Namespaced Legacy settings. Top-level legacy fields remain supported. */
+ legacy?: {
+ /** @deprecated The application bridge now uses child-process IPC. */
+ port?: number
+ /** Legacy CDP target port. Defaults to `0` (an OS-assigned loopback port). */
+ serverPort?: number
+ intercept?: InterceptOptions
+ /** Deterministic outbound request/response mocks. Legacy backend only. */
+ mock?: readonly LegacyMockRule[]
+ }
-export interface SetRequestInterceptorOptions {
/**
- * @description Main Process Port
- * @default 5270
+ * @deprecated The application bridge now uses child-process IPC. Move other
+ * Legacy settings under `legacy`.
*/
port?: number
- request?: RequestPipe
-}
-export interface SetResponseInterceptorOptions {
/**
- * @description Main Process Port
- * @default 5270
+ * @deprecated Use `legacy.serverPort`. Defaults to `0`.
*/
- port?: number
- response?: RequestPipe
-}
+ serverPort?: number
-export interface RemoveRequestInterceptorOptions {
/**
- * @description Main Process Port
- * @default 5270
+ * @deprecated Use `devtools.open`. Defaults to `false`.
*/
- port?: number
-}
+ autoOpenDevtool?: boolean
-export interface RemoveResponseInterceptorOptions {
/**
- * @description Main Process Port
- * @default 5270
+ * @description Options for intercepting different types of requests.
+ * If a property is set to `false`, that specific type of request will not be intercepted.
+ * By default, all are intercepted if not explicitly set.
*/
- port?: number
+ intercept?: InterceptOptions
}
diff --git a/packages/network-debugger/src/config/errors.ts b/packages/network-debugger/src/config/errors.ts
new file mode 100644
index 0000000..c05aff2
--- /dev/null
+++ b/packages/network-debugger/src/config/errors.ts
@@ -0,0 +1,17 @@
+export type NndConfigErrorCode =
+ | 'NND_CONFIG_NOT_FOUND'
+ | 'NND_CONFIG_LOAD_FAILED'
+ | 'NND_CONFIG_INVALID'
+ | 'NND_CONFIG_ENV_INVALID'
+
+export class NndConfigError extends Error {
+ constructor(
+ readonly code: NndConfigErrorCode,
+ message: string,
+ readonly details: Readonly> = {},
+ readonly cause?: unknown
+ ) {
+ super(message)
+ this.name = 'NndConfigError'
+ }
+}
diff --git a/packages/network-debugger/src/config/index.ts b/packages/network-debugger/src/config/index.ts
new file mode 100644
index 0000000..b363a5a
--- /dev/null
+++ b/packages/network-debugger/src/config/index.ts
@@ -0,0 +1,11 @@
+export * from './errors'
+export * from './loader'
+export * from './preload-config'
+export * from './types'
+
+import type { NndConfig } from './types'
+
+/** Type helper for nnd.config.mjs/cjs files. */
+export function defineConfig(config: T): T {
+ return config
+}
diff --git a/packages/network-debugger/src/config/loader.test.ts b/packages/network-debugger/src/config/loader.test.ts
new file mode 100644
index 0000000..77136d4
--- /dev/null
+++ b/packages/network-debugger/src/config/loader.test.ts
@@ -0,0 +1,176 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { NndConfigError } from './errors'
+import { findConfigFile, loadConfigFile, resolveConfig } from './loader'
+
+const temporaryDirectories: string[] = []
+
+function temporaryDirectory(): string {
+ const directory = mkdtempSync(join(tmpdir(), 'nnd-config-'))
+ temporaryDirectories.push(directory)
+ return directory
+}
+
+afterEach(() => {
+ for (const directory of temporaryDirectories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true })
+ }
+})
+
+describe('NND config loader', () => {
+ it('merges defaults, file, environment, and CLI in documented precedence', async () => {
+ const cwd = temporaryDirectory()
+ writeFileSync(
+ join(cwd, 'nnd.config.json'),
+ JSON.stringify({
+ mode: 'legacy',
+ open: true,
+ wait: false,
+ runner: 'tsx',
+ inspector: { host: 'file-host', port: 1111 },
+ requiredCapabilities: ['requestBody'],
+ session: { directory: '.nnd/session', har: true },
+ legacy: { port: 5000, serverPort: 5001 }
+ })
+ )
+
+ const result = await resolveConfig({
+ cwd,
+ env: {
+ NND_MODE: 'native',
+ NND_OPEN: 'false',
+ NND_INSPECTOR_PORT: '2222',
+ NND_WATCH: 'true'
+ },
+ cli: {
+ mode: 'auto',
+ wait: true,
+ inspector: { host: 'cli-host' },
+ requiredCapabilities: ['responseBody']
+ }
+ })
+
+ expect(result.config).toEqual({
+ mode: 'auto',
+ open: false,
+ wait: true,
+ watch: true,
+ runner: 'tsx',
+ inspector: { host: 'cli-host', port: 2222 },
+ requiredCapabilities: ['responseBody'],
+ session: { directory: '.nnd/session', har: true },
+ legacy: { port: 5000, serverPort: 5001 }
+ })
+ expect(result.sources.configFile).toBe(join(cwd, 'nnd.config.json'))
+ expect(result.sources.env).toEqual(['NND_MODE', 'NND_OPEN', 'NND_WATCH', 'NND_INSPECTOR_PORT'])
+ expect(result.sources.cli).toEqual(['mode', 'wait', 'requiredCapabilities', 'inspector'])
+ })
+
+ it('loads mjs, cjs, and json configuration files', async () => {
+ const cwd = temporaryDirectory()
+ const mjs = join(cwd, 'one.mjs')
+ const cjs = join(cwd, 'two.cjs')
+ const json = join(cwd, 'three.json')
+ writeFileSync(mjs, 'export default { mode: "native", open: true }\n')
+ writeFileSync(cjs, 'module.exports = { runner: "tsx", watch: true }\n')
+ writeFileSync(json, JSON.stringify({ inspector: { port: 42 } }))
+
+ await expect(loadConfigFile(mjs)).resolves.toMatchObject({ mode: 'native', open: true })
+ await expect(loadConfigFile(cjs)).resolves.toMatchObject({ runner: 'tsx', watch: true })
+ await expect(loadConfigFile(json)).resolves.toMatchObject({ inspector: { port: 42 } })
+ })
+
+ it('discovers config names deterministically and supports explicit nested paths', async () => {
+ const cwd = temporaryDirectory()
+ writeFileSync(join(cwd, 'nnd.config.json'), '{}')
+ writeFileSync(join(cwd, 'nnd.config.cjs'), 'module.exports = {}\n')
+ writeFileSync(join(cwd, 'nnd.config.mjs'), 'export default {}\n')
+ expect(findConfigFile(cwd)).toBe(join(cwd, 'nnd.config.mjs'))
+
+ const nested = join(cwd, 'configs')
+ mkdirSync(nested)
+ writeFileSync(join(nested, 'custom.json'), JSON.stringify({ mode: 'legacy' }))
+ await expect(
+ resolveConfig({ cwd, env: {}, configFile: 'configs/custom.json' })
+ ).resolves.toMatchObject({ config: { mode: 'legacy' } })
+ })
+
+ it('can disable file discovery', async () => {
+ const cwd = temporaryDirectory()
+ writeFileSync(join(cwd, 'nnd.config.json'), JSON.stringify({ open: true }))
+ const result = await resolveConfig({ cwd, env: {}, configFile: false })
+ expect(result.config.open).toBe(false)
+ expect(result.sources.configFile).toBeUndefined()
+ })
+
+ it('preserves serializable Legacy mock rules and rejects ambiguous bodies', async () => {
+ const cwd = temporaryDirectory()
+ const configPath = join(cwd, 'mock.json')
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ mode: 'auto',
+ legacy: {
+ mock: [
+ {
+ match: { url: 'https://example.test/*', method: 'POST' },
+ response: { status: 201, bodyBase64: 'AAEC/w==' }
+ }
+ ]
+ }
+ })
+ )
+ await expect(resolveConfig({ cwd, env: {}, configFile: configPath })).resolves.toMatchObject({
+ config: {
+ legacy: {
+ mock: [
+ {
+ match: { url: 'https://example.test/*', method: 'POST' },
+ response: { status: 201, bodyBase64: 'AAEC/w==' }
+ }
+ ]
+ }
+ }
+ })
+
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ legacy: {
+ mock: [{ match: { url: '*' }, response: { body: 'text', bodyBase64: 'dGV4dA==' } }]
+ }
+ })
+ )
+ await expect(loadConfigFile(configPath)).rejects.toMatchObject({
+ code: 'NND_CONFIG_INVALID'
+ })
+ })
+
+ it('uses stable actionable errors for missing, invalid, and malformed config', async () => {
+ const cwd = temporaryDirectory()
+ await expect(loadConfigFile(join(cwd, 'missing.json'))).rejects.toMatchObject({
+ code: 'NND_CONFIG_NOT_FOUND'
+ })
+
+ writeFileSync(join(cwd, 'bad.json'), '{ nope')
+ await expect(loadConfigFile(join(cwd, 'bad.json'))).rejects.toMatchObject({
+ code: 'NND_CONFIG_LOAD_FAILED'
+ })
+
+ writeFileSync(join(cwd, 'shape.json'), JSON.stringify({ runner: 'deno' }))
+ await expect(loadConfigFile(join(cwd, 'shape.json'))).rejects.toMatchObject({
+ code: 'NND_CONFIG_INVALID'
+ })
+
+ await expect(
+ resolveConfig({ cwd, configFile: false, env: { NND_OPEN: 'maybe' } })
+ ).rejects.toBeInstanceOf(NndConfigError)
+ await expect(
+ resolveConfig({ cwd, configFile: false, env: { NND_OPEN: 'maybe' } })
+ ).rejects.toMatchObject({
+ code: 'NND_CONFIG_ENV_INVALID'
+ })
+ })
+})
diff --git a/packages/network-debugger/src/config/loader.ts b/packages/network-debugger/src/config/loader.ts
new file mode 100644
index 0000000..65d5412
--- /dev/null
+++ b/packages/network-debugger/src/config/loader.ts
@@ -0,0 +1,511 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { isAbsolute, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { NETWORK_CAPABILITIES, type NetworkCapability } from '../adapters/types'
+import { NndConfigError } from './errors'
+import {
+ DEFAULT_NND_CONFIG,
+ type ConfigResolution,
+ type NndConfig,
+ type NndRunner,
+ type ResolveConfigOptions,
+ type ResolvedNndConfig
+} from './types'
+
+const CONFIG_NAMES = ['nnd.config.mjs', 'nnd.config.cjs', 'nnd.config.json'] as const
+const MODES = new Set(['auto', 'native', 'legacy'])
+const RUNNERS = new Set(['node', 'tsx'])
+const CAPABILITIES = new Set(NETWORK_CAPABILITIES)
+
+const hasOwn = (value: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(value, key)
+
+function invalid(
+ message: string,
+ details: Readonly> = {},
+ source: 'config' | 'env' = 'config'
+): never {
+ throw new NndConfigError(
+ source === 'env' ? 'NND_CONFIG_ENV_INVALID' : 'NND_CONFIG_INVALID',
+ message,
+ details
+ )
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function assertBoolean(value: unknown, field: string): asserts value is boolean {
+ if (typeof value !== 'boolean')
+ invalid(`Configuration field "${field}" must be a boolean.`, { field, value })
+}
+
+function assertPort(value: unknown, field: string): asserts value is number {
+ if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 65_535) {
+ invalid(`Configuration field "${field}" must be an integer from 0 to 65535.`, {
+ field,
+ value
+ })
+ }
+}
+
+function validateMockRules(value: unknown, sourcePath?: string): void {
+ if (!Array.isArray(value)) {
+ invalid('Configuration field "legacy.mock" must be an array.', {
+ field: 'legacy.mock',
+ value,
+ sourcePath
+ })
+ }
+ for (const [index, rule] of value.entries()) {
+ if (
+ !isRecord(rule) ||
+ !isRecord(rule.match) ||
+ typeof rule.match.url !== 'string' ||
+ rule.match.url.length === 0 ||
+ !isRecord(rule.response)
+ ) {
+ invalid(`Configuration field "legacy.mock[${index}]" is invalid.`, {
+ field: `legacy.mock[${index}]`,
+ value: rule,
+ sourcePath
+ })
+ }
+ if (rule.match.method !== undefined && typeof rule.match.method !== 'string') {
+ invalid(`Configuration field "legacy.mock[${index}].match.method" must be a string.`, {
+ field: `legacy.mock[${index}].match.method`,
+ value: rule.match.method,
+ sourcePath
+ })
+ }
+ if (rule.match.headers !== undefined && !isRecord(rule.match.headers)) {
+ invalid(`Configuration field "legacy.mock[${index}].match.headers" must be an object.`, {
+ field: `legacy.mock[${index}].match.headers`,
+ value: rule.match.headers,
+ sourcePath
+ })
+ }
+ if (rule.response.body !== undefined && typeof rule.response.body !== 'string') {
+ invalid(`Configuration field "legacy.mock[${index}].response.body" must be a string.`, {
+ field: `legacy.mock[${index}].response.body`,
+ value: rule.response.body,
+ sourcePath
+ })
+ }
+ if (rule.response.bodyBase64 !== undefined && typeof rule.response.bodyBase64 !== 'string') {
+ invalid(`Configuration field "legacy.mock[${index}].response.bodyBase64" must be a string.`, {
+ field: `legacy.mock[${index}].response.bodyBase64`,
+ value: rule.response.bodyBase64,
+ sourcePath
+ })
+ }
+ if (rule.response.body !== undefined && rule.response.bodyBase64 !== undefined) {
+ invalid(
+ `Configuration field "legacy.mock[${index}].response" cannot define both body and bodyBase64.`,
+ { field: `legacy.mock[${index}].response`, sourcePath }
+ )
+ }
+ }
+}
+
+function validateConfig(value: unknown, sourcePath?: string): NndConfig {
+ if (!isRecord(value)) {
+ return invalid('NND configuration must export an object.', { sourcePath })
+ }
+
+ const config = value as Record
+ if (config.mode !== undefined && (typeof config.mode !== 'string' || !MODES.has(config.mode))) {
+ invalid('Configuration field "mode" must be auto, native, or legacy.', {
+ field: 'mode',
+ value: config.mode,
+ sourcePath
+ })
+ }
+ if (config.runner !== undefined && !RUNNERS.has(config.runner as NndRunner)) {
+ invalid('Configuration field "runner" must be node or tsx.', {
+ field: 'runner',
+ value: config.runner,
+ sourcePath
+ })
+ }
+ for (const field of ['open', 'wait', 'watch'] as const) {
+ if (config[field] !== undefined) assertBoolean(config[field], field)
+ }
+
+ if (config.inspector !== undefined) {
+ if (!isRecord(config.inspector)) {
+ invalid('Configuration field "inspector" must be an object.', {
+ field: 'inspector',
+ value: config.inspector,
+ sourcePath
+ })
+ }
+ if (config.inspector.host !== undefined && typeof config.inspector.host !== 'string') {
+ invalid('Configuration field "inspector.host" must be a string.', {
+ field: 'inspector.host',
+ value: config.inspector.host,
+ sourcePath
+ })
+ }
+ if (config.inspector.host === '') {
+ invalid('Configuration field "inspector.host" cannot be empty.', {
+ field: 'inspector.host',
+ sourcePath
+ })
+ }
+ if (config.inspector.port !== undefined) assertPort(config.inspector.port, 'inspector.port')
+ }
+
+ if (config.requiredCapabilities !== undefined) {
+ if (
+ !Array.isArray(config.requiredCapabilities) ||
+ config.requiredCapabilities.some(
+ (capability) => typeof capability !== 'string' || !CAPABILITIES.has(capability)
+ )
+ ) {
+ invalid('Configuration field "requiredCapabilities" contains an unknown capability.', {
+ field: 'requiredCapabilities',
+ value: config.requiredCapabilities,
+ allowed: [...NETWORK_CAPABILITIES],
+ sourcePath
+ })
+ }
+ }
+
+ if (config.session !== undefined) {
+ if (!isRecord(config.session)) {
+ invalid('Configuration field "session" must be an object.', {
+ field: 'session',
+ value: config.session,
+ sourcePath
+ })
+ }
+ if (typeof config.session.directory !== 'string' || !config.session.directory.trim()) {
+ invalid('Configuration field "session.directory" must be a non-empty string.', {
+ field: 'session.directory',
+ value: config.session.directory,
+ sourcePath
+ })
+ }
+ if (
+ config.session.bodyCommandTimeoutMs !== undefined &&
+ (!Number.isSafeInteger(config.session.bodyCommandTimeoutMs) ||
+ (config.session.bodyCommandTimeoutMs as number) <= 0)
+ ) {
+ invalid('Configuration field "session.bodyCommandTimeoutMs" must be a positive integer.', {
+ field: 'session.bodyCommandTimeoutMs',
+ value: config.session.bodyCommandTimeoutMs,
+ sourcePath
+ })
+ }
+ if (
+ config.session.har !== undefined &&
+ typeof config.session.har !== 'boolean' &&
+ (typeof config.session.har !== 'string' || !config.session.har.trim())
+ ) {
+ invalid('Configuration field "session.har" must be a boolean or non-empty path.', {
+ field: 'session.har',
+ value: config.session.har,
+ sourcePath
+ })
+ }
+ }
+
+ if (config.legacy !== undefined) {
+ if (!isRecord(config.legacy)) {
+ invalid('Configuration field "legacy" must be an object.', {
+ field: 'legacy',
+ value: config.legacy,
+ sourcePath
+ })
+ }
+ if (config.legacy.port !== undefined) assertPort(config.legacy.port, 'legacy.port')
+ if (config.legacy.serverPort !== undefined) {
+ assertPort(config.legacy.serverPort, 'legacy.serverPort')
+ }
+ if (config.legacy.intercept !== undefined && !isRecord(config.legacy.intercept)) {
+ invalid('Configuration field "legacy.intercept" must be an object.', {
+ field: 'legacy.intercept',
+ value: config.legacy.intercept,
+ sourcePath
+ })
+ }
+ if (config.legacy.mock !== undefined) {
+ validateMockRules(config.legacy.mock, sourcePath)
+ }
+ }
+
+ return config as unknown as NndConfig
+}
+
+function resolveConfigPath(cwd: string, requested: string): string {
+ return isAbsolute(requested) ? requested : resolve(cwd, requested)
+}
+
+export function findConfigFile(cwd: string): string | undefined {
+ for (const name of CONFIG_NAMES) {
+ const candidate = resolve(cwd, name)
+ if (existsSync(candidate)) return candidate
+ }
+ return undefined
+}
+
+export async function loadConfigFile(path: string): Promise {
+ if (!existsSync(path)) {
+ throw new NndConfigError(
+ 'NND_CONFIG_NOT_FOUND',
+ `NND configuration file was not found: ${path}`,
+ {
+ path
+ }
+ )
+ }
+
+ try {
+ let value: unknown
+ if (path.endsWith('.json')) {
+ value = JSON.parse(readFileSync(path, 'utf8'))
+ } else if (path.endsWith('.cjs')) {
+ const requireFromConfig = createRequire(resolve(path, '..', '__nnd_loader__.cjs'))
+ const resolved = requireFromConfig.resolve(path)
+ delete requireFromConfig.cache[resolved]
+ const loaded = requireFromConfig(resolved) as unknown
+ value = isRecord(loaded) && hasOwn(loaded, 'default') ? loaded.default : loaded
+ } else if (path.endsWith('.mjs')) {
+ const url = `${pathToFileURL(path).href}?nnd=${Date.now()}`
+ const loaded = (await import(/* @vite-ignore */ url)) as { default?: unknown }
+ value = hasOwn(loaded, 'default') ? loaded.default : loaded
+ } else {
+ invalid('NND configuration files must use .mjs, .cjs, or .json.', { path })
+ }
+ return validateConfig(value, path)
+ } catch (error) {
+ if (error instanceof NndConfigError) throw error
+ throw new NndConfigError(
+ 'NND_CONFIG_LOAD_FAILED',
+ `Unable to load NND configuration: ${path}`,
+ { path, cause: error instanceof Error ? error.message : String(error) },
+ error
+ )
+ }
+}
+
+function parseBoolean(value: string, key: string): boolean {
+ const normalized = value.trim().toLowerCase()
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
+ return invalid(`Environment variable ${key} must be true or false.`, { key, value }, 'env')
+}
+
+function parsePort(value: string, key: string): number {
+ const port = Number(value)
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
+ return invalid(
+ `Environment variable ${key} must be an integer from 0 to 65535.`,
+ {
+ key,
+ value
+ },
+ 'env'
+ )
+ }
+ return port
+}
+
+function parseCapabilities(value: string, key: string): readonly NetworkCapability[] {
+ const capabilities = value
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+ const unknown = capabilities.filter((capability) => !CAPABILITIES.has(capability))
+ if (unknown.length > 0) {
+ return invalid(
+ `Environment variable ${key} contains unknown capabilities: ${unknown.join(', ')}.`,
+ {
+ key,
+ unknown,
+ allowed: [...NETWORK_CAPABILITIES]
+ },
+ 'env'
+ )
+ }
+ return [...new Set(capabilities)] as NetworkCapability[]
+}
+
+interface EnvironmentConfig {
+ config: NndConfig
+ keys: string[]
+}
+
+function configFromEnvironment(env: NodeJS.ProcessEnv): EnvironmentConfig {
+ const config: NndConfig = {}
+ const keys: string[] = []
+ const use = (key: string, apply: (value: string) => void) => {
+ const value = env[key]
+ if (value === undefined || value === '') return
+ keys.push(key)
+ apply(value)
+ }
+
+ use('NND_MODE', (value) => {
+ if (!MODES.has(value)) {
+ invalid(
+ 'Environment variable NND_MODE must be auto, native, or legacy.',
+ {
+ key: 'NND_MODE',
+ value
+ },
+ 'env'
+ )
+ }
+ config.mode = value as NndConfig['mode']
+ })
+ use('NND_OPEN', (value) => (config.open = parseBoolean(value, 'NND_OPEN')))
+ use('NND_WAIT', (value) => (config.wait = parseBoolean(value, 'NND_WAIT')))
+ use('NND_WATCH', (value) => (config.watch = parseBoolean(value, 'NND_WATCH')))
+ use('NND_RUNNER', (value) => {
+ if (!RUNNERS.has(value as NndRunner)) {
+ invalid(
+ 'Environment variable NND_RUNNER must be node or tsx.',
+ {
+ key: 'NND_RUNNER',
+ value
+ },
+ 'env'
+ )
+ }
+ config.runner = value as NndRunner
+ })
+ use('NND_INSPECTOR_HOST', (value) => {
+ if (!value.trim()) {
+ invalid(
+ 'Environment variable NND_INSPECTOR_HOST cannot be empty.',
+ {
+ key: 'NND_INSPECTOR_HOST'
+ },
+ 'env'
+ )
+ }
+ config.inspector = { ...config.inspector, host: value }
+ })
+ use('NND_INSPECTOR_PORT', (value) => {
+ config.inspector = { ...config.inspector, port: parsePort(value, 'NND_INSPECTOR_PORT') }
+ })
+ use('NND_REQUIRED_CAPABILITIES', (value) => {
+ config.requiredCapabilities = parseCapabilities(value, 'NND_REQUIRED_CAPABILITIES')
+ })
+ use('NND_LEGACY_PORT', (value) => {
+ config.legacy = { ...config.legacy, port: parsePort(value, 'NND_LEGACY_PORT') }
+ })
+ use('NND_LEGACY_SERVER_PORT', (value) => {
+ config.legacy = {
+ ...config.legacy,
+ serverPort: parsePort(value, 'NND_LEGACY_SERVER_PORT')
+ }
+ })
+
+ return { config, keys }
+}
+
+function mergeConfig(base: NndConfig, override: NndConfig): NndConfig {
+ return {
+ ...base,
+ ...override,
+ inspector:
+ base.inspector || override.inspector
+ ? { ...base.inspector, ...override.inspector }
+ : undefined,
+ session: override.session ?? base.session,
+ legacy:
+ base.legacy || override.legacy
+ ? {
+ ...base.legacy,
+ ...override.legacy,
+ intercept:
+ base.legacy?.intercept || override.legacy?.intercept
+ ? { ...base.legacy?.intercept, ...override.legacy?.intercept }
+ : undefined,
+ mock: override.legacy?.mock ?? base.legacy?.mock
+ }
+ : undefined
+ }
+}
+
+function explicitKeys(config: NndConfig): string[] {
+ const keys: string[] = []
+ for (const key of [
+ 'mode',
+ 'open',
+ 'wait',
+ 'watch',
+ 'runner',
+ 'requiredCapabilities',
+ 'session'
+ ] as const) {
+ if (config[key] !== undefined) keys.push(key)
+ }
+ if (config.inspector !== undefined) keys.push('inspector')
+ if (config.legacy !== undefined) keys.push('legacy')
+ return keys
+}
+
+function finalize(config: NndConfig): ResolvedNndConfig {
+ const merged = mergeConfig(DEFAULT_NND_CONFIG, config)
+ return {
+ mode: merged.mode!,
+ open: merged.open!,
+ wait: merged.wait!,
+ watch: merged.watch!,
+ runner: merged.runner!,
+ inspector: {
+ host: merged.inspector!.host!,
+ port: merged.inspector!.port!
+ },
+ requiredCapabilities: [...(merged.requiredCapabilities ?? [])],
+ ...(merged.session
+ ? {
+ session: {
+ directory: merged.session.directory,
+ ...(merged.session.bodyCommandTimeoutMs !== undefined
+ ? { bodyCommandTimeoutMs: merged.session.bodyCommandTimeoutMs }
+ : {}),
+ ...(merged.session.har !== undefined ? { har: merged.session.har } : {})
+ }
+ }
+ : {}),
+ legacy: {
+ ...(merged.legacy?.port !== undefined ? { port: merged.legacy.port } : {}),
+ ...(merged.legacy?.serverPort !== undefined ? { serverPort: merged.legacy.serverPort } : {}),
+ ...(merged.legacy?.intercept !== undefined
+ ? { intercept: { ...merged.legacy.intercept } }
+ : {}),
+ ...(merged.legacy?.mock !== undefined ? { mock: [...merged.legacy.mock] } : {})
+ }
+ }
+}
+
+export async function resolveConfig(options: ResolveConfigOptions = {}): Promise {
+ const cwd = resolve(options.cwd ?? process.cwd())
+ const env = options.env ?? process.env
+ const requestedConfig = options.configFile ?? env.NND_CONFIG
+ const configPath =
+ requestedConfig === false
+ ? undefined
+ : typeof requestedConfig === 'string' && requestedConfig !== ''
+ ? resolveConfigPath(cwd, requestedConfig)
+ : findConfigFile(cwd)
+ const fileConfig = configPath ? await loadConfigFile(configPath) : {}
+ const environment = configFromEnvironment(env)
+ const cliConfig = options.cli ? validateConfig(options.cli) : {}
+ const config = finalize(mergeConfig(mergeConfig(fileConfig, environment.config), cliConfig))
+
+ return {
+ config,
+ sources: {
+ ...(configPath ? { configFile: configPath } : {}),
+ env: environment.keys,
+ cli: explicitKeys(cliConfig)
+ }
+ }
+}
diff --git a/packages/network-debugger/src/config/preload-config.test.ts b/packages/network-debugger/src/config/preload-config.test.ts
new file mode 100644
index 0000000..5e74178
--- /dev/null
+++ b/packages/network-debugger/src/config/preload-config.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { parsePreloadConfig, serializePreloadConfig, toRegisterOptions } from './preload-config'
+import type { ResolvedNndConfig } from './types'
+
+const config: ResolvedNndConfig = {
+ mode: 'native',
+ open: true,
+ wait: false,
+ watch: true,
+ runner: 'tsx',
+ inspector: { host: '127.0.0.1', port: 0 },
+ requiredCapabilities: ['responseBody'],
+ session: { directory: '.nnd/session', bodyCommandTimeoutMs: 2500, har: true },
+ legacy: { port: 5000 }
+}
+
+describe('preload configuration transport', () => {
+ it('round trips resolved config and excludes CLI-owned frontend opening', () => {
+ expect(parsePreloadConfig(serializePreloadConfig(config))).toEqual(config)
+ expect(toRegisterOptions(config)).toEqual({
+ mode: 'native',
+ requiredCapabilities: ['responseBody'],
+ inspector: { host: '127.0.0.1', port: 0 },
+ devtools: { open: false },
+ session: { directory: '.nnd/session', bodyCommandTimeoutMs: 2500, har: true },
+ legacy: { port: 5000 }
+ })
+ })
+
+ it('reports malformed serialized environment values with a stable code', () => {
+ expect(() => parsePreloadConfig('{')).toThrowError(
+ expect.objectContaining({ code: 'NND_CONFIG_ENV_INVALID' })
+ )
+ expect(() => parsePreloadConfig(JSON.stringify({ mode: 'wat' }))).toThrowError(
+ expect.objectContaining({ code: 'NND_CONFIG_ENV_INVALID' })
+ )
+ expect(() => parsePreloadConfig(JSON.stringify({ ...config, session: {} }))).toThrowError(
+ expect.objectContaining({ code: 'NND_CONFIG_ENV_INVALID' })
+ )
+ })
+})
diff --git a/packages/network-debugger/src/config/preload-config.ts b/packages/network-debugger/src/config/preload-config.ts
new file mode 100644
index 0000000..12cec7b
--- /dev/null
+++ b/packages/network-debugger/src/config/preload-config.ts
@@ -0,0 +1,60 @@
+import type { RegisterOptions } from '../common'
+import { NndConfigError } from './errors'
+import type { ResolvedNndConfig } from './types'
+
+export const NND_PRELOAD_CONFIG_ENV = 'NND_PRELOAD_CONFIG'
+
+export function toRegisterOptions(config: ResolvedNndConfig): RegisterOptions {
+ return {
+ mode: config.mode,
+ requiredCapabilities: config.requiredCapabilities,
+ inspector: config.inspector,
+ devtools: { open: false },
+ ...(config.session ? { session: config.session } : {}),
+ legacy: config.legacy
+ }
+}
+
+export function serializePreloadConfig(config: ResolvedNndConfig): string {
+ return JSON.stringify(config)
+}
+
+export function parsePreloadConfig(value: string): ResolvedNndConfig {
+ try {
+ const parsed = JSON.parse(value) as ResolvedNndConfig
+ if (
+ !parsed ||
+ typeof parsed !== 'object' ||
+ !['auto', 'native', 'legacy'].includes(parsed.mode) ||
+ typeof parsed.open !== 'boolean' ||
+ typeof parsed.wait !== 'boolean' ||
+ typeof parsed.watch !== 'boolean' ||
+ !['node', 'tsx'].includes(parsed.runner) ||
+ !parsed.inspector ||
+ typeof parsed.inspector.host !== 'string' ||
+ !Number.isInteger(parsed.inspector.port) ||
+ !Array.isArray(parsed.requiredCapabilities) ||
+ (parsed.session !== undefined &&
+ (!parsed.session ||
+ typeof parsed.session !== 'object' ||
+ typeof parsed.session.directory !== 'string' ||
+ !parsed.session.directory.trim() ||
+ (parsed.session.bodyCommandTimeoutMs !== undefined &&
+ (!Number.isSafeInteger(parsed.session.bodyCommandTimeoutMs) ||
+ parsed.session.bodyCommandTimeoutMs <= 0)) ||
+ (parsed.session.har !== undefined &&
+ typeof parsed.session.har !== 'boolean' &&
+ (typeof parsed.session.har !== 'string' || !parsed.session.har.trim()))))
+ ) {
+ throw new Error('serialized configuration has an invalid shape')
+ }
+ return parsed
+ } catch (error) {
+ throw new NndConfigError(
+ 'NND_CONFIG_ENV_INVALID',
+ `${NND_PRELOAD_CONFIG_ENV} does not contain a valid resolved configuration.`,
+ { cause: error instanceof Error ? error.message : String(error) },
+ error
+ )
+ }
+}
diff --git a/packages/network-debugger/src/config/types.ts b/packages/network-debugger/src/config/types.ts
new file mode 100644
index 0000000..31d3890
--- /dev/null
+++ b/packages/network-debugger/src/config/types.ts
@@ -0,0 +1,84 @@
+import type { InterceptOptions } from '../common'
+import type { AdapterMode, NetworkCapability } from '../adapters/types'
+import type { LegacyMockRule } from '../mock'
+
+export type NndRunner = 'node' | 'tsx'
+
+/** Values accepted by nnd.config.mjs/cjs/json and explicit CLI overrides. */
+export interface NndConfig {
+ mode?: AdapterMode
+ open?: boolean
+ wait?: boolean
+ watch?: boolean
+ runner?: NndRunner
+ inspector?: {
+ host?: string
+ port?: number
+ }
+ requiredCapabilities?: readonly NetworkCapability[]
+ session?: {
+ directory: string
+ bodyCommandTimeoutMs?: number
+ har?: boolean | string
+ }
+ legacy?: {
+ port?: number
+ serverPort?: number
+ intercept?: InterceptOptions
+ mock?: readonly LegacyMockRule[]
+ }
+}
+
+export interface ResolvedNndConfig {
+ mode: AdapterMode
+ open: boolean
+ wait: boolean
+ watch: boolean
+ runner: NndRunner
+ inspector: {
+ host: string
+ port: number
+ }
+ requiredCapabilities: readonly NetworkCapability[]
+ session?: {
+ directory: string
+ bodyCommandTimeoutMs?: number
+ har?: boolean | string
+ }
+ legacy: {
+ port?: number
+ serverPort?: number
+ intercept?: InterceptOptions
+ mock?: readonly LegacyMockRule[]
+ }
+}
+
+export interface ConfigSources {
+ configFile?: string
+ env: readonly string[]
+ cli: readonly string[]
+}
+
+export interface ConfigResolution {
+ config: ResolvedNndConfig
+ sources: ConfigSources
+}
+
+export interface ResolveConfigOptions {
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ cli?: NndConfig
+ /** Explicit file path. `false` disables config-file discovery. */
+ configFile?: string | false
+}
+
+export const DEFAULT_NND_CONFIG: Readonly = Object.freeze({
+ mode: 'auto',
+ open: false,
+ wait: true,
+ watch: false,
+ runner: 'node',
+ inspector: Object.freeze({ host: '127.0.0.1', port: 0 }),
+ requiredCapabilities: Object.freeze([]),
+ legacy: Object.freeze({})
+})
diff --git a/packages/network-debugger/src/core/capture-scope.ts b/packages/network-debugger/src/core/capture-scope.ts
new file mode 100644
index 0000000..798181b
--- /dev/null
+++ b/packages/network-debugger/src/core/capture-scope.ts
@@ -0,0 +1,12 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+
+const legacyCaptureSuppression = new AsyncLocalStorage()
+
+/** Run internal debugger transport setup without observing it as application traffic. */
+export function withoutLegacyCapture(operation: () => T): T {
+ return legacyCaptureSuppression.run(true, operation)
+}
+
+export function isLegacyCaptureSuppressed(): boolean {
+ return legacyCaptureSuppression.getStore() === true
+}
diff --git a/packages/network-debugger/src/core/fetch.test.ts b/packages/network-debugger/src/core/fetch.test.ts
index 5b0e936..e4b1b04 100644
--- a/packages/network-debugger/src/core/fetch.test.ts
+++ b/packages/network-debugger/src/core/fetch.test.ts
@@ -1,769 +1,282 @@
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
-import { RequestDetail } from '../common'
-import { proxyFetch, fetchProxyFactory } from './fetch'
+import { deserialize, serialize } from 'node:v8'
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+import type { MainProcess } from './fork'
+import { fetchProxyFactory, proxyFetch, SseParser } from './fetch'
import * as cellModule from './hooks/cell'
-// Mock setCurrentCell
+const cellState = vi.hoisted(() => ({ current: null as unknown }))
+
vi.mock('./hooks/cell', () => ({
- setCurrentCell: vi.fn(),
- getCurrentCell: vi.fn()
+ setCurrentCell: vi.fn((cell: unknown) => {
+ cellState.current = cell
+ }),
+ getCurrentCell: vi.fn(() => cellState.current)
}))
-// 创建 mock MainProcess
-function createMockMainProcess() {
- const mockSendRequest = vi.fn()
- const mockMainProcess = {
- sendRequest: mockSendRequest.mockReturnThis()
- }
- return { mockMainProcess, mockSendRequest }
+interface JournalEntry {
+ transport: 'request' | 'event'
+ type: string
+ data: any
}
-// 创建 mock Response
-function createMockResponse(
- options: {
- status?: number
- headers?: Record
- body?: string | Buffer
- } = {}
-) {
- const { status = 200, headers = {}, body = '' } = options
- const mockHeaders = new Headers(headers)
-
- const arrayBuffer = typeof body === 'string' ? new TextEncoder().encode(body).buffer : body.buffer
-
- return {
- status,
- headers: mockHeaders,
- clone: vi.fn().mockReturnValue({
- arrayBuffer: vi.fn().mockResolvedValue(arrayBuffer)
- })
- } as unknown as Response
+function snapshot(value: T): T {
+ return deserialize(serialize(value)) as T
}
-describe('core/fetch.ts', () => {
- let originalFetch: typeof globalThis.fetch | undefined
-
- beforeEach(() => {
- vi.clearAllMocks()
- originalFetch = globalThis.fetch
+function createMainProcess() {
+ const journal: JournalEntry[] = []
+ const mainProcess: Record = {}
+ mainProcess.sendRequest = vi.fn((type: string, data: unknown) => {
+ journal.push({ transport: 'request', type, data: snapshot(data) })
+ return mainProcess
})
-
- afterEach(() => {
- // 恢复原始 fetch
- if (originalFetch !== undefined) {
- globalThis.fetch = originalFetch
- }
+ mainProcess.send = vi.fn(async (event: { type: string; data: unknown }) => {
+ journal.push({ transport: 'event', type: event.type, data: snapshot(event.data) })
})
+ return { journal, mainProcess: mainProcess as MainProcess }
+}
- describe('proxyFetch 函数', () => {
- test('当 globalThis.fetch 不存在时,直接返回 undefined', () => {
- // 临时删除 fetch
- const savedFetch = globalThis.fetch
- // @ts-expect-error - 测试 fetch 不存在的情况
- delete globalThis.fetch
-
- const { mockMainProcess } = createMockMainProcess()
- const result = proxyFetch(mockMainProcess as never)
-
- expect(result).toBeUndefined()
-
- // 恢复
- globalThis.fetch = savedFetch
- })
-
- test('当 globalThis.fetch 存在时,替换为代理函数', () => {
- const mockFetch = vi.fn()
- globalThis.fetch = mockFetch
-
- const { mockMainProcess } = createMockMainProcess()
- const unset = proxyFetch(mockMainProcess as never)
-
- expect(globalThis.fetch).not.toBe(mockFetch)
- expect(typeof unset).toBe('function')
- })
+function fetchMock(response: Response): typeof fetch {
+ return vi.fn().mockResolvedValue(response) as unknown as typeof fetch
+}
- test('调用返回的 unset 函数后恢复原始 fetch', () => {
- const mockFetch = vi.fn()
- globalThis.fetch = mockFetch
+function streamResponse(chunks: string[]): Response {
+ const encoder = new TextEncoder()
+ const body = new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
+ controller.close()
+ }
+ })
+ return new Response(body, {
+ status: 200,
+ headers: { 'content-type': 'text/event-stream; charset=utf-8' }
+ })
+}
- const { mockMainProcess } = createMockMainProcess()
- const unset = proxyFetch(mockMainProcess as never)
+async function waitFor(journal: JournalEntry[], type: string): Promise {
+ await vi.waitFor(() => expect(journal.some((entry) => entry.type === type)).toBe(true))
+ return journal.find((entry) => entry.type === type)!
+}
- expect(globalThis.fetch).not.toBe(mockFetch)
+describe('fetch capture', () => {
+ let savedFetch: typeof globalThis.fetch | undefined
- // 调用 unset
- unset!()
+ beforeEach(() => {
+ vi.clearAllMocks()
+ cellState.current = null
+ savedFetch = globalThis.fetch
+ })
- expect(globalThis.fetch).toBe(mockFetch)
- })
+ afterEach(() => {
+ if (savedFetch) globalThis.fetch = savedFetch
+ else Reflect.deleteProperty(globalThis, 'fetch')
})
- describe('fetchProxyFactory 函数', () => {
- describe('请求 URL 处理', () => {
- test('处理字符串 URL', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- // 验证 sendRequest 被调用,且 URL 正确
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'https://example.com/api'
- })
- )
- })
-
- test('处理 URL 对象', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const url = new URL('https://example.com/api')
- await proxyFn(url)
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'https://example.com/api'
- })
- )
- })
-
- test('处理 Request 对象(URL 不会被提取)', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- // 注意:Request 对象的 URL 不会被提取到 requestDetail.url
- // 因为代码只检查 string 和 URL 类型
- const request = new Request('https://example.com/api')
- await proxyFn(request)
-
- // URL 应该是 undefined,因为 Request 类型不被处理
- // RequestDetail 初始化时 url 属性未定义
- const initRequestCall = mockSendRequest.mock.calls.find((call) => call[0] === 'initRequest')
- expect(initRequestCall).toBeDefined()
- const requestDetail = initRequestCall![1] as RequestDetail
- expect(requestDetail.url).toBeUndefined()
- })
- })
+ test('installs and unsets the global proxy without overwriting a later owner', () => {
+ const original = vi.fn() as unknown as typeof fetch
+ globalThis.fetch = original
+ const { mainProcess } = createMainProcess()
+
+ const unset = proxyFetch(mainProcess)!
+ expect(globalThis.fetch).not.toBe(original)
+ unset()
+ expect(globalThis.fetch).toBe(original)
+
+ const unsetAgain = proxyFetch(mainProcess)!
+ const replacement = vi.fn() as unknown as typeof fetch
+ globalThis.fetch = replacement
+ unsetAgain()
+ expect(globalThis.fetch).toBe(replacement)
+ })
- describe('请求方法处理', () => {
- test('默认方法为 GET', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- method: 'GET'
- })
- )
- })
-
- test('使用 options 中指定的方法', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api', { method: 'POST' })
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- method: 'POST'
- })
- )
- })
- })
+ test('does nothing when the runtime has no global fetch', () => {
+ Reflect.deleteProperty(globalThis, 'fetch')
+ const { mainProcess } = createMainProcess()
+ expect(proxyFetch(mainProcess)).toBeUndefined()
+ })
- describe('请求头处理', () => {
- test('处理 Headers 对象', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const headers = new Headers({
- 'Content-Type': 'application/json',
- Authorization: 'Bearer token'
- })
- await proxyFn('https://example.com/api', { headers })
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- requestHeaders: {
- 'content-type': 'application/json',
- authorization: 'Bearer token'
- }
- })
- )
- })
-
- test('处理普通对象头部', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const headers = {
- 'Content-Type': 'application/json',
- 'X-Custom-Header': 'custom-value'
- }
- await proxyFn('https://example.com/api', { headers })
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- requestHeaders: headers
- })
- )
- })
-
- test('没有头部时使用空对象', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- requestHeaders: {}
- })
- )
- })
+ test('captures a Request URL, merged headers, body, and a seconds timestamp', async () => {
+ const response = new Response(null, { status: 204 })
+ const original = fetchMock(response)
+ const { journal, mainProcess } = createMainProcess()
+ const request = new Request('http://127.0.0.1:43871/actual?value=1', {
+ method: 'PUT',
+ headers: { 'X-From-Request': 'base' }
})
+ const before = Date.now() / 1000
- describe('请求体处理', () => {
- test('记录请求体数据', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const body = JSON.stringify({ key: 'value' })
- await proxyFn('https://example.com/api', { method: 'POST', body })
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- requestData: body
- })
- )
- })
+ await fetchProxyFactory(original, mainProcess)(request, {
+ method: 'PATCH',
+ headers: { 'X-From-Options': 'override' },
+ body: 'payload'
})
-
- describe('setCurrentCell 调用', () => {
- test('请求开始时设置 cell', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- expect(cellModule.setCurrentCell).toHaveBeenCalledWith(
- expect.objectContaining({
- request: expect.any(RequestDetail),
- pipes: [],
- isAborted: false
- })
- )
- })
-
- test('请求完成后清除 cell', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- // 最后一次调用应该是 null
- const calls = vi.mocked(cellModule.setCurrentCell).mock.calls
- expect(calls[calls.length - 1][0]).toBeNull()
- })
+ const after = Date.now() / 1000
+
+ const detail = journal.find((entry) => entry.type === 'initRequest')!.data
+ expect(detail).toMatchObject({
+ url: 'http://127.0.0.1:43871/actual?value=1',
+ method: 'PATCH',
+ requestData: 'payload',
+ requestHeaders: {
+ 'x-from-request': 'base',
+ 'x-from-options': 'override'
+ }
})
+ expect(detail.requestStartTime).toBeGreaterThanOrEqual(before)
+ expect(detail.requestStartTime).toBeLessThanOrEqual(after)
+ expect(detail.requestStartTime).toBeLessThan(10_000_000_000)
+ expect(original).toHaveBeenCalledWith(request, expect.objectContaining({ body: 'payload' }))
+ })
- describe('MainProcess 消息发送', () => {
- test('发送 initRequest 和 registerRequest', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- expect(mockSendRequest).toHaveBeenCalledWith('initRequest', expect.any(RequestDetail))
- expect(mockSendRequest).toHaveBeenCalledWith('registerRequest', expect.any(RequestDetail))
- })
+ test('emits responseReceived before the terminal body event', async () => {
+ const response = new Response('captured body', {
+ status: 201,
+ statusText: 'Created',
+ headers: { 'content-type': 'text/plain', 'x-result': 'yes' }
})
-
- describe('成功响应处理', () => {
- test('记录响应状态码', async () => {
- const mockResponse = createMockResponse({ status: 201 })
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- // 等待异步操作完成
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'updateRequest',
- expect.objectContaining({
- responseStatusCode: 201
- })
- )
- })
-
- test('记录响应头', async () => {
- const mockResponse = createMockResponse({
- headers: { 'Content-Type': 'application/json' }
- })
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'updateRequest',
- expect.objectContaining({
- responseHeaders: { 'content-type': 'application/json' }
- })
- )
- })
-
- test('记录响应体数据', async () => {
- const responseBody = 'Hello, World!'
- const mockResponse = createMockResponse({ body: responseBody })
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'updateRequest',
- expect.objectContaining({
- responseData: expect.any(Buffer)
- })
- )
- })
-
- test('记录响应数据长度', async () => {
- const responseBody = 'Hello, World!'
- const mockResponse = createMockResponse({ body: responseBody })
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'updateRequest',
- expect.objectContaining({
- responseInfo: expect.objectContaining({
- dataLength: responseBody.length,
- encodedDataLength: responseBody.length
- })
- })
- )
- })
-
- test('发送 updateRequest 和 endRequest', async () => {
- const mockResponse = createMockResponse()
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- expect(mockSendRequest).toHaveBeenCalledWith('updateRequest', expect.any(RequestDetail))
- expect(mockSendRequest).toHaveBeenCalledWith('endRequest', expect.any(RequestDetail))
- })
-
- test('返回原始 Response 对象', async () => {
- const mockResponse = createMockResponse()
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const result = await proxyFn('https://example.com/api')
-
- expect(result).toBe(mockResponse)
- })
-
- test('响应状态码为 0 时正确处理', async () => {
- const mockResponse = createMockResponse({ status: 0 })
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'updateRequest',
- expect.objectContaining({
- responseStatusCode: 0
- })
- )
- })
+ const { journal, mainProcess } = createMainProcess()
+
+ const returned = await fetchProxyFactory(
+ fetchMock(response),
+ mainProcess
+ )('https://example.test/resource')
+ const terminal = await waitFor(journal, 'endRequest')
+
+ expect(returned).toBe(response)
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'responseReceived',
+ 'endRequest'
+ ])
+ const received = journal.find((entry) => entry.type === 'responseReceived')!.data
+ expect(received).toMatchObject({
+ responseStatusCode: 201,
+ responseStatusText: 'Created',
+ responseHeaders: { 'content-type': 'text/plain', 'x-result': 'yes' }
})
+ expect(Buffer.from(terminal.data.responseData).toString()).toBe('captured body')
+ expect(terminal.data.responseInfo).toEqual({ dataLength: 13, encodedDataLength: 13 })
+ expect(terminal.data.requestEndTime).toBeLessThan(10_000_000_000)
+ expect(cellState.current).toBeNull()
+ })
- describe('错误响应处理', () => {
- test('请求失败时记录状态码为 0', async () => {
- const error = new Error('Network error')
- const mockFetch = vi.fn().mockRejectedValue(error)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
-
- await expect(proxyFn('https://example.com/api')).rejects.toThrow('Network error')
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'updateRequest',
- expect.objectContaining({
- responseStatusCode: 0
- })
- )
- })
-
- test('请求失败时发送 updateRequest 和 endRequest', async () => {
- const error = new Error('Network error')
- const mockFetch = vi.fn().mockRejectedValue(error)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
-
- await expect(proxyFn('https://example.com/api')).rejects.toThrow()
-
- expect(mockSendRequest).toHaveBeenCalledWith('updateRequest', expect.any(RequestDetail))
- expect(mockSendRequest).toHaveBeenCalledWith('endRequest', expect.any(RequestDetail))
- })
-
- test('请求失败时重新抛出错误', async () => {
- const error = new Error('Custom error message')
- const mockFetch = vi.fn().mockRejectedValue(error)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
+ test('reports a rejected fetch only as requestFailed and rethrows it', async () => {
+ const failure = new Error('connection refused')
+ const original = vi.fn().mockRejectedValue(failure) as unknown as typeof fetch
+ const { journal, mainProcess } = createMainProcess()
+
+ await expect(
+ fetchProxyFactory(original, mainProcess)('http://127.0.0.1:49999/unavailable')
+ ).rejects.toBe(failure)
+
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'requestFailed'
+ ])
+ const failed = journal.at(-1)!.data
+ expect(failed.errorText).toBe('connection refused')
+ expect(failed.request.requestEndTime).toBeLessThan(10_000_000_000)
+ expect(cellState.current).toBeNull()
+ })
- await expect(proxyFn('https://example.com/api')).rejects.toThrow('Custom error message')
- })
+ test('reports body-capture failure after headers without a successful terminal event', async () => {
+ const response = {
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers({ 'content-type': 'application/octet-stream' }),
+ clone: () => ({ arrayBuffer: () => Promise.reject(new Error('stream reset')) })
+ } as unknown as Response
+ const { journal, mainProcess } = createMainProcess()
+
+ await fetchProxyFactory(fetchMock(response), mainProcess)('https://example.test/reset')
+ await waitFor(journal, 'requestFailed')
+
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'responseReceived',
+ 'requestFailed'
+ ])
+ expect(journal.some(({ type }) => type === 'endRequest')).toBe(false)
+ expect(journal.at(-1)!.data).toMatchObject({ errorText: 'stream reset', canceled: true })
+ })
- test('请求失败后清除 cell', async () => {
- const error = new Error('Network error')
- const mockFetch = vi.fn().mockRejectedValue(error)
- const { mockMainProcess } = createMockMainProcess()
+ test('SseParser handles split CRLF delimiters, multiline data, and persistent ids', () => {
+ const messages: Array<{ eventName: string; eventId: string; data: string }> = []
+ const parser = new SseParser((message) => messages.push(message))
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
+ parser.push('id: 7\r')
+ parser.push('\nevent: update\r\ndata: first\r')
+ parser.push('\ndata: second\r\n\r')
+ parser.push('\n: ignored\r\ndata: tail')
+ parser.finish()
- await expect(proxyFn('https://example.com/api')).rejects.toThrow()
+ expect(messages).toEqual([
+ { eventName: 'update', eventId: '7', data: 'first\nsecond' },
+ { eventName: 'message', eventId: '7', data: 'tail' }
+ ])
+ })
- // 最后一次调用应该是 null
- const calls = vi.mocked(cellModule.setCurrentCell).mock.calls
- expect(calls[calls.length - 1][0]).toBeNull()
- })
- })
+ test('SseParser flushes an unterminated final event and ignores events without data', () => {
+ const messages: Array<{ eventName: string; eventId: string; data: string }> = []
+ const parser = new SseParser((message) => messages.push(message))
- describe('时间戳记录', () => {
- test('记录请求开始时间', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const beforeTime = Date.now()
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
- const afterTime = Date.now()
-
- const initRequestCall = mockSendRequest.mock.calls.find((call) => call[0] === 'initRequest')
- expect(initRequestCall).toBeDefined()
- const requestDetail = initRequestCall![1] as RequestDetail
- expect(requestDetail.requestStartTime).toBeGreaterThanOrEqual(beforeTime)
- expect(requestDetail.requestStartTime).toBeLessThanOrEqual(afterTime)
- })
-
- test('记录请求结束时间', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- const updateRequestCall = mockSendRequest.mock.calls.find(
- (call) => call[0] === 'updateRequest'
- )
- expect(updateRequestCall).toBeDefined()
- const requestDetail = updateRequestCall![1] as RequestDetail
- expect(requestDetail.requestEndTime).toBeDefined()
- expect(requestDetail.requestEndTime).toBeGreaterThan(0)
- })
- })
+ parser.push('event: ignored\n\nid: stable\n')
+ parser.finish('data: final')
- describe('原始 fetch 调用', () => {
- test('正确传递参数给原始 fetch', async () => {
- const mockFetch = vi.fn().mockResolvedValue(createMockResponse())
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const options: RequestInit = {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key: 'value' })
- }
- await proxyFn('https://example.com/api', options)
-
- expect(mockFetch).toHaveBeenCalledWith('https://example.com/api', options)
- })
- })
+ expect(messages).toEqual([{ eventName: 'message', eventId: 'stable', data: 'final' }])
+ })
- describe('SSE (Server-Sent Events) 处理', () => {
- // Helper to create a mock SSE response
- function createMockSSEResponse(events: string[], options: { delay?: number } = {}) {
- const { delay = 0 } = options
- const mockHeaders = new Headers({
- 'content-type': 'text/event-stream'
- })
-
- // Create a mock ReadableStream
- let readerIndex = 0
- const encoder = new TextEncoder()
- const chunks = events.map((event) => encoder.encode(event))
-
- const mockReader = {
- read: vi.fn().mockImplementation(async () => {
- if (delay > 0) {
- await new Promise((resolve) => setTimeout(resolve, delay))
- }
- if (readerIndex >= chunks.length) {
- return { done: true, value: undefined }
- }
- const value = chunks[readerIndex]
- readerIndex++
- return { done: false, value }
- })
- }
-
- const mockBody = {
- getReader: vi.fn().mockReturnValue(mockReader)
- }
-
- return {
- status: 200,
- headers: mockHeaders,
- body: mockBody,
- clone: vi.fn().mockReturnValue({
- body: mockBody,
- arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
- })
- } as unknown as Response
- }
+ test('streams SSE messages across chunks before one successful terminal event', async () => {
+ const chunks = [
+ 'id: 7\r',
+ '\nevent: update\r\ndata: first\r',
+ '\ndata: second\r\n\r',
+ '\ndata: tail'
+ ]
+ const response = streamResponse(chunks)
+ const { journal, mainProcess } = createMainProcess()
+
+ await fetchProxyFactory(fetchMock(response), mainProcess)('http://127.0.0.1:43777/events')
+ const terminal = await waitFor(journal, 'endRequest')
+
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'eventSourceResponseReceived',
+ 'eventSourceMessage',
+ 'eventSourceMessage',
+ 'endRequest'
+ ])
+ expect(
+ journal
+ .filter(({ type }) => type === 'eventSourceMessage')
+ .map(({ data }) => ({ eventName: data.eventName, eventId: data.eventId, data: data.data }))
+ ).toEqual([
+ { eventName: 'update', eventId: '7', data: 'first\nsecond' },
+ { eventName: 'message', eventId: '7', data: 'tail' }
+ ])
+ expect(Buffer.from(terminal.data.responseData).toString()).toBe(chunks.join(''))
+ })
- test('正确识别 SSE 响应 (text/event-stream)', async () => {
- const mockResponse = createMockSSEResponse(['data: hello\n\n'])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- const result = await proxyFn('https://example.com/sse')
-
- // SSE 响应应该直接返回,不等待流结束
- expect(result).toBe(mockResponse)
- })
-
- test('解析简单的 SSE 事件', async () => {
- const mockResponse = createMockSSEResponse(['data: hello world\n\n'])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/sse')
-
- // 等待流处理完成
- await new Promise((resolve) => setTimeout(resolve, 50))
-
- expect(mockSend).toHaveBeenCalledWith({
- type: 'eventSourceMessage',
- data: {
- requestId: expect.any(String),
- eventName: 'message',
- eventId: '',
- data: 'hello world'
- }
- })
- })
-
- test('解析带有 event 类型的 SSE 事件', async () => {
- const mockResponse = createMockSSEResponse(['event: custom\ndata: test data\n\n'])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/sse')
-
- await new Promise((resolve) => setTimeout(resolve, 50))
-
- expect(mockSend).toHaveBeenCalledWith({
- type: 'eventSourceMessage',
- data: {
- requestId: expect.any(String),
- eventName: 'custom',
- eventId: '',
- data: 'test data'
- }
- })
- })
-
- test('解析带有 id 的 SSE 事件', async () => {
- const mockResponse = createMockSSEResponse(['id: 123\ndata: with id\n\n'])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/sse')
-
- await new Promise((resolve) => setTimeout(resolve, 50))
-
- expect(mockSend).toHaveBeenCalledWith({
- type: 'eventSourceMessage',
- data: {
- requestId: expect.any(String),
- eventName: 'message',
- eventId: '123',
- data: 'with id'
- }
- })
- })
-
- test('处理多行 data', async () => {
- const mockResponse = createMockSSEResponse(['data: line1\ndata: line2\n\n'])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/sse')
-
- await new Promise((resolve) => setTimeout(resolve, 50))
-
- expect(mockSend).toHaveBeenCalledWith({
- type: 'eventSourceMessage',
- data: {
- requestId: expect.any(String),
- eventName: 'message',
- eventId: '',
- data: 'line1\nline2'
- }
- })
- })
-
- test('处理多个连续的 SSE 事件', async () => {
- const mockResponse = createMockSSEResponse([
- 'data: first\n\n',
- 'data: second\n\n',
- 'data: third\n\n'
- ])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/sse')
-
- await new Promise((resolve) => setTimeout(resolve, 100))
-
- const eventSourceCalls = mockSend.mock.calls.filter(
- (call) => call[0]?.type === 'eventSourceMessage'
- )
- expect(eventSourceCalls.length).toBe(3)
- expect(eventSourceCalls[0][0].data.data).toBe('first')
- expect(eventSourceCalls[1][0].data.data).toBe('second')
- expect(eventSourceCalls[2][0].data.data).toBe('third')
- })
-
- test('SSE 流结束后发送 endRequest', async () => {
- const mockResponse = createMockSSEResponse(['data: test\n\n'])
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/sse')
-
- await new Promise((resolve) => setTimeout(resolve, 50))
-
- expect(mockSendRequest).toHaveBeenCalledWith('endRequest', expect.any(RequestDetail))
- })
-
- test('非 SSE 响应不触发 eventSourceMessage', async () => {
- const mockResponse = createMockResponse({
- headers: { 'content-type': 'application/json' },
- body: '{"key": "value"}'
- })
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
- const mockSend = vi.fn()
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockMainProcess = {
- sendRequest: mockSendRequest,
- send: mockSend
- }
-
- const proxyFn = fetchProxyFactory(mockFetch, mockMainProcess as never)
- await proxyFn('https://example.com/api')
-
- await new Promise((resolve) => setTimeout(resolve, 50))
-
- const eventSourceCalls = mockSend.mock.calls.filter(
- (call) => call[0]?.type === 'eventSourceMessage'
- )
- expect(eventSourceCalls.length).toBe(0)
- })
- })
+ test('clears only the async-context cell owned by the completing fetch', async () => {
+ let resolveFirst!: (response: Response) => void
+ const firstFetch = vi.fn(
+ () => new Promise((resolve) => (resolveFirst = resolve))
+ ) as unknown as typeof fetch
+ const { mainProcess } = createMainProcess()
+ const firstPromise = fetchProxyFactory(firstFetch, mainProcess)('https://example.test/first')
+ const firstCell = cellState.current
+
+ cellState.current = { request: 'newer context' }
+ resolveFirst(new Response('done'))
+ await firstPromise
+
+ expect(cellState.current).toEqual({ request: 'newer context' })
+ expect(vi.mocked(cellModule.setCurrentCell)).not.toHaveBeenCalledWith(null)
+ expect(firstCell).not.toBeNull()
})
})
diff --git a/packages/network-debugger/src/core/fetch.ts b/packages/network-debugger/src/core/fetch.ts
index a9326eb..4301a24 100644
--- a/packages/network-debugger/src/core/fetch.ts
+++ b/packages/network-debugger/src/core/fetch.ts
@@ -1,243 +1,266 @@
import { RequestDetail } from '../common'
+import { findFetchMock, mockedFetchResponse, type LegacyMockRule } from '../mock'
import { headersToObject } from '../utils/map'
-import { MainProcess } from './fork'
-import { setCurrentCell } from './hooks/cell'
+import type { MainProcess } from './fork'
+import { getCurrentCell, setCurrentCell, type Cell } from './hooks/cell'
+import { isLegacyCaptureSuppressed } from './capture-scope'
-export function proxyFetch(mainProcess: MainProcess) {
- if (!globalThis.fetch) {
- return
- }
+export function proxyFetch(mainProcess: MainProcess, mockRules: readonly LegacyMockRule[] = []) {
+ if (!globalThis.fetch) return
const originalFetch = globalThis.fetch
-
- globalThis['fetch'] = fetchProxyFactory(originalFetch, mainProcess)
+ const proxy =
+ mockRules.length > 0
+ ? fetchProxyFactory(originalFetch, mainProcess, mockRules)
+ : fetchProxyFactory(originalFetch, mainProcess)
+ globalThis.fetch = proxy
return () => {
- globalThis['fetch'] = originalFetch
+ if (globalThis.fetch === proxy) globalThis.fetch = originalFetch
}
}
-export function fetchProxyFactory(fetchFn: typeof fetch, mainProcess: MainProcess) {
- return function (request: string | URL | Request, options?: RequestInit) {
- const requestDetail = new RequestDetail()
- requestDetail.requestStartTime = Date.now()
- setCurrentCell({ request: requestDetail, pipes: [], isAborted: false })
+interface SseMessage {
+ eventName: string
+ eventId: string
+ data: string
+}
- if (typeof request === 'string') {
- requestDetail.url = request
- } else if (request instanceof URL) {
- requestDetail.url = request.toString()
+/** Incremental WHATWG event-stream parser; fields may span arbitrary chunks. */
+export class SseParser {
+ private buffer = ''
+ private eventName = 'message'
+ private eventId = ''
+ private data: string[] = []
+ private sawData = false
+
+ constructor(private readonly emit: (message: SseMessage) => void) {}
+
+ push(text: string): void {
+ this.buffer += text
+ this.drain(false)
+ }
+
+ finish(text = ''): void {
+ this.buffer += text
+ this.drain(true)
+ if (this.buffer) {
+ this.line(this.buffer)
+ this.buffer = ''
}
+ this.dispatch()
+ }
- requestDetail.method = options?.method ?? 'GET'
+ private drain(final: boolean): void {
+ while (true) {
+ const match = /\r\n|\r|\n/.exec(this.buffer)
+ if (!match) return
+ // CRLF is one line ending even when the transport splits it across
+ // chunks. Hold a trailing CR until the next chunk (or EOF) decides it.
+ if (!final && match[0] === '\r' && match.index === this.buffer.length - 1) return
+ const line = this.buffer.slice(0, match.index)
+ this.buffer = this.buffer.slice(match.index + match[0].length)
+ this.line(line)
+ }
+ }
- const headers = options?.headers
- if (headers instanceof Headers) {
- const headersObj = headersToObject(headers)
- requestDetail.requestHeaders = headersObj
- } else {
- requestDetail.requestHeaders = headers ?? {}
+ private line(line: string): void {
+ if (line === '') {
+ this.dispatch()
+ return
}
- requestDetail.requestData = options?.body
+ if (line.startsWith(':')) return
- requestDetail.loadCallFrames()
+ const separator = line.indexOf(':')
+ const field = separator === -1 ? line : line.slice(0, separator)
+ let value = separator === -1 ? '' : line.slice(separator + 1)
+ if (value.startsWith(' ')) value = value.slice(1)
- const result = fetchFn(request as string | Request, options)
- .then(fetchResponseHandlerFactory(requestDetail, mainProcess))
- .catch(fetchErrorHandlerFactory(requestDetail, mainProcess))
- .finally(() => {
- setCurrentCell(null)
+ if (field === 'event') {
+ this.eventName = value || 'message'
+ } else if (field === 'data') {
+ this.sawData = true
+ this.data.push(value)
+ } else if (field === 'id' && !value.includes('\0')) {
+ this.eventId = value
+ }
+ }
+
+ private dispatch(): void {
+ if (this.sawData) {
+ this.emit({
+ eventName: this.eventName || 'message',
+ eventId: this.eventId,
+ data: this.data.join('\n')
})
+ }
+ this.eventName = 'message'
+ this.data = []
+ this.sawData = false
+ }
+}
- mainProcess
- .sendRequest('initRequest', requestDetail)
- .sendRequest('registerRequest', requestDetail)
+function bodyValue(body: BodyInit | null | undefined): unknown {
+ if (body === undefined || body === null) return undefined
+ if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) return body
+ if (body instanceof URLSearchParams) return body.toString()
+ return undefined
+}
- return result
+function populateFetchRequest(
+ detail: RequestDetail,
+ request: string | URL | Request,
+ options?: RequestInit
+): void {
+ if (typeof request === 'string') detail.url = request
+ else if (request instanceof URL) detail.url = request.toString()
+ else detail.url = request.url
+
+ detail.method = options?.method ?? (request instanceof Request ? request.method : 'GET')
+ const headers = new Headers(request instanceof Request ? request.headers : undefined)
+ if (options?.headers) {
+ new Headers(options.headers).forEach((value, key) => headers.set(key, value))
}
+ detail.requestHeaders = headersToObject(headers)
+ detail.requestData = bodyValue(options?.body)
+ detail.requestStartTime = Date.now() / 1000
+ detail.responseHeaders = {}
+ detail.loadCallFrames()
+}
+
+function failureText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error || 'Fetch failed')
+}
+
+function responseInto(detail: RequestDetail, response: Response): void {
+ detail.responseHeaders = headersToObject(response.headers)
+ detail.responseStatusCode = response.status || 0
+ ;(detail as RequestDetail & { responseStatusText?: string }).responseStatusText =
+ response.statusText
}
-/**
- * Check if the response is a Server-Sent Events (SSE) stream
- */
function isEventStream(response: Response): boolean {
- const contentType = response.headers.get('content-type') || ''
- return contentType.includes('text/event-stream')
+ return (response.headers.get('content-type') ?? '').includes('text/event-stream')
}
-/**
- * Handle SSE (Server-Sent Events) streaming response
- */
-async function handleEventStreamResponse(
+async function captureEventStream(
response: Response,
- requestDetail: RequestDetail,
+ detail: RequestDetail,
mainProcess: MainProcess
): Promise {
- const body = response.clone().body
- if (!body) {
+ const stream = response.clone().body
+ if (!stream) {
+ detail.responseData = Buffer.alloc(0)
+ detail.responseInfo = { dataLength: 0, encodedDataLength: 0 }
+ mainProcess.sendRequest('endRequest', detail)
return
}
- const reader = body.getReader()
+ const chunks: Buffer[] = []
const decoder = new TextDecoder()
- let buffer = ''
- const allChunks: Uint8Array[] = []
-
- // Send responseReceived first (type: EventSource) so DevTools knows this is an SSE request
- mainProcess.send({
- type: 'eventSourceResponseReceived',
- data: requestDetail
+ const parser = new SseParser((message) => {
+ void mainProcess.send({
+ type: 'eventSourceMessage',
+ data: { requestId: detail.id, ...message }
+ })
})
+ const reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
-
- if (done) {
- break
- }
-
- if (value) {
- allChunks.push(value)
- buffer += decoder.decode(value, { stream: true })
-
- // Parse SSE events from buffer
- const lines = buffer.split('\n')
- buffer = lines.pop() || '' // Keep incomplete line in buffer
-
- let currentEventType = 'message'
- let currentEventData = ''
- let currentEventId = ''
-
- for (const line of lines) {
- if (line.startsWith('event:')) {
- currentEventType = line.slice(6).trim()
- } else if (line.startsWith('data:')) {
- currentEventData += (currentEventData ? '\n' : '') + line.slice(5).trim()
- } else if (line.startsWith('id:')) {
- currentEventId = line.slice(3).trim()
- } else if (line === '') {
- // Empty line means end of event
- if (currentEventData) {
- mainProcess.send({
- type: 'eventSourceMessage',
- data: {
- requestId: requestDetail.id,
- eventName: currentEventType,
- eventId: currentEventId,
- data: currentEventData
- }
- })
- }
- // Reset for next event
- currentEventType = 'message'
- currentEventData = ''
- currentEventId = ''
- }
- }
- }
- }
-
- // Handle any remaining data in buffer
- if (buffer.trim()) {
- const lines = buffer.split('\n')
- let currentEventType = 'message'
- let currentEventData = ''
- let currentEventId = ''
-
- for (const line of lines) {
- if (line.startsWith('event:')) {
- currentEventType = line.slice(6).trim()
- } else if (line.startsWith('data:')) {
- currentEventData += (currentEventData ? '\n' : '') + line.slice(5).trim()
- } else if (line.startsWith('id:')) {
- currentEventId = line.slice(3).trim()
- }
- }
-
- if (currentEventData) {
- mainProcess.send({
- type: 'eventSourceMessage',
- data: {
- requestId: requestDetail.id,
- eventName: currentEventType,
- eventId: currentEventId,
- data: currentEventData
- }
- })
- }
+ if (done) break
+ if (!value) continue
+ chunks.push(Buffer.from(value))
+ parser.push(decoder.decode(value, { stream: true }))
}
+ parser.finish(decoder.decode())
+ const body = Buffer.concat(chunks)
+ detail.responseData = body
+ detail.responseInfo = { dataLength: body.length, encodedDataLength: body.length }
+ detail.requestEndTime = Date.now() / 1000
+ mainProcess.sendRequest('endRequest', detail)
+ } catch (error) {
+ detail.requestEndTime = Date.now() / 1000
+ await mainProcess.send({
+ type: 'requestFailed',
+ data: { request: detail, errorText: failureText(error), canceled: true }
+ })
+ }
+}
- // Combine all chunks for final response data
- const totalLength = allChunks.reduce((acc, chunk) => acc + chunk.length, 0)
- const combinedArray = new Uint8Array(totalLength)
- let offset = 0
- for (const chunk of allChunks) {
- combinedArray.set(chunk, offset)
- offset += chunk.length
- }
+function captureResponse(
+ response: Response,
+ detail: RequestDetail,
+ mainProcess: MainProcess
+): void {
+ responseInto(detail, response)
+ void mainProcess.send({
+ type: isEventStream(response) ? 'eventSourceResponseReceived' : 'responseReceived',
+ data: detail
+ })
- requestDetail.responseData = Buffer.from(combinedArray)
- requestDetail.responseInfo.dataLength = totalLength
- requestDetail.responseInfo.encodedDataLength = totalLength
- } catch (error) {
- // Stream was aborted or errored, still try to save what we have
- if (allChunks.length > 0) {
- const totalLength = allChunks.reduce((acc, chunk) => acc + chunk.length, 0)
- const combinedArray = new Uint8Array(totalLength)
- let offset = 0
- for (const chunk of allChunks) {
- combinedArray.set(chunk, offset)
- offset += chunk.length
- }
- requestDetail.responseData = Buffer.from(combinedArray)
- requestDetail.responseInfo.dataLength = totalLength
- requestDetail.responseInfo.encodedDataLength = totalLength
- }
- } finally {
- requestDetail.requestEndTime = Date.now()
- mainProcess.sendRequest('updateRequest', requestDetail).sendRequest('endRequest', requestDetail)
+ if (isEventStream(response)) {
+ void captureEventStream(response, detail, mainProcess)
+ return
}
+
+ void response
+ .clone()
+ .arrayBuffer()
+ .then((arrayBuffer) => {
+ const body = Buffer.from(arrayBuffer)
+ detail.responseData = body
+ detail.responseInfo = { dataLength: body.length, encodedDataLength: body.length }
+ detail.requestEndTime = Date.now() / 1000
+ mainProcess.sendRequest('endRequest', detail)
+ })
+ .catch(async (error) => {
+ detail.requestEndTime = Date.now() / 1000
+ await mainProcess.send({
+ type: 'requestFailed',
+ data: { request: detail, errorText: failureText(error), canceled: true }
+ })
+ })
}
-function fetchResponseHandlerFactory(requestDetail: RequestDetail, mainProcess: MainProcess) {
- return (response: Response) => {
- requestDetail.requestEndTime = new Date().getTime()
- requestDetail.responseHeaders = headersToObject(response.headers)
- requestDetail.responseStatusCode = response.status || 0
-
- // Check if this is an SSE stream
- if (isEventStream(response)) {
- // Handle SSE asynchronously without blocking the response
- handleEventStreamResponse(response, requestDetail, mainProcess)
- return response
+export function fetchProxyFactory(
+ fetchFn: typeof fetch,
+ mainProcess: MainProcess,
+ mockRules: readonly LegacyMockRule[] = []
+): typeof fetch {
+ return function fetchProxy(request: string | URL | Request, options?: RequestInit) {
+ if (isLegacyCaptureSuppressed()) {
+ return fetchFn(request as string | Request, options)
}
+ const detail = new RequestDetail()
+ populateFetchRequest(detail, request, options)
+ const cell: Cell = { request: detail, pipes: [], isAborted: false }
+ setCurrentCell(cell)
- // Handle regular response
- response
- .clone()
- .arrayBuffer()
- .then((buffer) => {
- const responseData = Buffer.from(buffer)
- requestDetail.responseData = responseData
- requestDetail.responseInfo.dataLength = responseData.length
- // TODO: use content-encoding to determine the actual length
- requestDetail.responseInfo.encodedDataLength = responseData.length
- })
- .finally(() => {
- mainProcess
- .sendRequest('updateRequest', requestDetail)
- .sendRequest('endRequest', requestDetail)
- })
+ mainProcess.sendRequest('initRequest', detail).sendRequest('registerRequest', detail)
- return response
- }
-}
+ const mockRule = findFetchMock(mockRules, request, options)
+ const responsePromise = mockRule
+ ? mockedFetchResponse(mockRule, options?.signal)
+ : fetchFn(request as string | Request, options)
-function fetchErrorHandlerFactory(requestDetail: RequestDetail, mainProcess: MainProcess) {
- return (err: unknown) => {
- requestDetail.requestEndTime = Date.now()
- requestDetail.responseStatusCode = 0
- mainProcess.sendRequest('updateRequest', requestDetail).sendRequest('endRequest', requestDetail)
- throw err
- }
+ return responsePromise
+ .then(
+ (response) => {
+ captureResponse(response, detail, mainProcess)
+ return response
+ },
+ async (error) => {
+ detail.requestEndTime = Date.now() / 1000
+ await mainProcess.send({
+ type: 'requestFailed',
+ data: { request: detail, errorText: failureText(error) }
+ })
+ throw error
+ }
+ )
+ .finally(() => {
+ if (getCurrentCell() === cell) setCurrentCell(null)
+ })
+ } as typeof fetch
}
diff --git a/packages/network-debugger/src/core/fork.test.ts b/packages/network-debugger/src/core/fork.test.ts
index 5b4e060..831bb03 100644
--- a/packages/network-debugger/src/core/fork.test.ts
+++ b/packages/network-debugger/src/core/fork.test.ts
@@ -1,597 +1,259 @@
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
-import { EventEmitter } from 'events'
-import { RequestDetail, READY_MESSAGE } from '../common'
-import type { IncomingMessage } from 'http'
-
-// 使用 vi.hoisted 确保变量在 mock 提升时可用
-const {
- mockWsSend,
- mockWsTerminate,
- mockWsRemoveAllListeners,
- mockFork,
- mockCpKill,
- mockCpRemoveAllListeners,
- mockExistsSync,
- mockReadFileSync,
- mockWriteFileSync,
- mockSleep,
- mockCheckMainProcessAlive,
- mockUnlinkSafe,
- mockWarn,
- mockGetCurrentCell,
- mockGenerateUUID,
- wsInstances,
- cpInstances
-} = vi.hoisted(() => {
- let uuidCounter = 0
- return {
- mockWsSend: vi.fn(),
- mockWsTerminate: vi.fn(),
- mockWsRemoveAllListeners: vi.fn(),
- mockFork: vi.fn(),
- mockCpKill: vi.fn(),
- mockCpRemoveAllListeners: vi.fn(),
- mockExistsSync: vi.fn().mockReturnValue(false),
- mockReadFileSync: vi.fn().mockReturnValue('12345'),
- mockWriteFileSync: vi.fn(),
- mockSleep: vi.fn().mockResolvedValue(undefined),
- mockCheckMainProcessAlive: vi.fn().mockResolvedValue(false),
- mockUnlinkSafe: vi.fn(),
- mockWarn: vi.fn(),
- mockGetCurrentCell: vi.fn().mockReturnValue(null),
- mockGenerateUUID: vi.fn().mockImplementation(() => `mock-uuid-${++uuidCounter}`),
- wsInstances: [] as EventEmitter[],
- cpInstances: [] as EventEmitter[]
+import { EventEmitter } from 'node:events'
+import { describe, expect, test, vi } from 'vitest'
+import type { DevtoolsTarget, Diagnostic } from '../adapters/types'
+import { RequestDetail } from '../common'
+import type { LegacyBridgeError } from '../legacy-bridge/client'
+import type { LegacyCaptureEvent } from '../legacy-bridge/contracts'
+import { MainProcess } from './fork'
+import { setCurrentCell } from './hooks/cell'
+
+const target: DevtoolsTarget = {
+ id: 'legacy',
+ title: 'Legacy',
+ type: 'node',
+ url: '',
+ webSocketDebuggerUrl: 'ws://127.0.0.1:43120/devtools/page/legacy',
+ discoveryUrl: 'http://127.0.0.1:43120/json/list'
+}
+
+function bridgeHarness() {
+ const events: LegacyCaptureEvent[] = []
+ const listeners = new Set<(diagnostic: Diagnostic) => void>()
+ const failureListeners = new Set<(error: LegacyBridgeError) => void>()
+ const bridge = {
+ ready: Promise.resolve(target),
+ send: vi.fn(async (event: LegacyCaptureEvent) => {
+ events.push(event)
+ }),
+ onDiagnostic: vi.fn((listener: (diagnostic: Diagnostic) => void) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ }),
+ onFailure: vi.fn((listener: (error: LegacyBridgeError) => void) => {
+ failureListeners.add(listener)
+ return () => failureListeners.delete(listener)
+ }),
+ dispose: vi.fn(async () => undefined)
}
-})
-
-// Mock ws 模块 - 使用正确的继承方式
-vi.mock('ws', () => {
- const { EventEmitter } = require('events')
+ const main = new MainProcess({ key: 'compat-key', port: 5270, serverPort: 0 }, { bridge })
+ return { main, bridge, events, listeners, failureListeners }
+}
+
+function request(id = 'request-1'): RequestDetail {
+ const detail = new RequestDetail()
+ detail.id = id
+ detail.url = 'http://example.test/'
+ detail.method = 'GET'
+ detail.requestHeaders = {}
+ return detail
+}
+
+class FakeResponse extends EventEmitter {
+ statusCode = 200
+ statusMessage = 'OK'
+ headers = { 'content-type': 'text/plain', 'content-encoding': 'gzip' }
+ complete = false
+}
+
+describe('MainProcess IPC compatibility facade', () => {
+ test('exposes ready/diagnostics and keeps the chainable sendRequest API', async () => {
+ const { main, bridge, events, listeners, failureListeners } = bridgeHarness()
+ const detail = request()
+ const onFailure = vi.fn()
+ main.onFailure(onFailure)
+ expect(failureListeners.has(onFailure)).toBe(true)
+ const diagnosticListener = vi.fn()
+
+ expect(main.onDiagnostic(diagnosticListener)).toEqual(expect.any(Function))
+ expect(listeners.has(diagnosticListener)).toBe(true)
+ expect(main.sendRequest('initRequest', detail)).toBe(main)
+ expect(main.sendRequest('registerRequest', detail)).toBe(main)
+ await expect(main.ready).resolves.toEqual(target)
+ expect(events.map((event) => event.type)).toEqual(['initRequest', 'registerRequest'])
+
+ await main.dispose()
+ expect(bridge.dispose).toHaveBeenCalledOnce()
+ })
- function MockWebSocket(this: EventEmitter) {
- EventEmitter.call(this)
- this.send = mockWsSend
- this.terminate = mockWsTerminate
- const originalRemoveAllListeners = this.removeAllListeners.bind(this)
- this.removeAllListeners = function () {
- mockWsRemoveAllListeners()
- return originalRemoveAllListeners()
+ test('binds pipes and abort state by request id across concurrent async lifecycles', async () => {
+ const { main, events } = bridgeHarness()
+ const first = request('concurrent-a')
+ const second = request('concurrent-b')
+ const firstCell = {
+ request: first,
+ isAborted: false,
+ pipes: [
+ {
+ type: 'updateRequest' as const,
+ pipe: (detail: RequestDetail) => Object.assign(new RequestDetail(detail), { method: 'A' })
+ }
+ ]
}
- wsInstances.push(this)
- }
-
- // 正确继承 EventEmitter
- MockWebSocket.prototype = Object.create(EventEmitter.prototype)
- MockWebSocket.prototype.constructor = MockWebSocket
-
- return {
- default: MockWebSocket
- }
-})
-
-// Mock child_process 模块
-vi.mock('child_process', () => {
- const { EventEmitter } = require('events')
-
- return {
- fork: function () {
- mockFork()
- const cp = new EventEmitter()
- cp.send = vi.fn().mockReturnValue(true)
- cp.kill = mockCpKill
- const originalRemoveAllListeners = cp.removeAllListeners.bind(cp)
- cp.removeAllListeners = function () {
- mockCpRemoveAllListeners()
- return originalRemoveAllListeners()
- }
- cpInstances.push(cp)
- return cp
+ const secondCell = {
+ request: second,
+ isAborted: false,
+ pipes: [
+ {
+ type: 'updateRequest' as const,
+ pipe: (detail: RequestDetail) => Object.assign(new RequestDetail(detail), { method: 'B' })
+ }
+ ]
}
- }
-})
-
-// Mock fs 模块
-vi.mock('fs', () => ({
- default: {
- existsSync: mockExistsSync,
- readFileSync: mockReadFileSync,
- writeFileSync: mockWriteFileSync
- }
-}))
-
-// Mock utils/process 模块
-vi.mock('../utils/process', () => ({
- sleep: mockSleep,
- checkMainProcessAlive: mockCheckMainProcessAlive
-}))
-// Mock utils/file 模块
-vi.mock('../utils/file', () => ({
- unlinkSafe: mockUnlinkSafe
-}))
-
-// Mock utils 模块 - 添加 generateUUID
-vi.mock('../utils', () => ({
- warn: mockWarn,
- generateUUID: mockGenerateUUID
-}))
-
-// Mock hooks/cell 模块
-vi.mock('./hooks/cell', () => ({
- getCurrentCell: mockGetCurrentCell
-}))
-
-describe('core/fork.ts', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- vi.clearAllMocks()
- wsInstances.length = 0
- cpInstances.length = 0
- mockExistsSync.mockReturnValue(false)
- mockCheckMainProcessAlive.mockResolvedValue(false)
- mockGetCurrentCell.mockReturnValue(null)
- })
-
- afterEach(() => {
- vi.useRealTimers()
- vi.restoreAllMocks()
+ setCurrentCell(firstCell)
+ main.sendRequest('initRequest', first)
+ setCurrentCell(secondCell)
+ main.sendRequest('initRequest', second)
+ // The global cell now belongs to B; A must still use its own pipe/state.
+ main.sendRequest('updateRequest', first)
+ firstCell.isAborted = true
+ main.sendRequest('updateRequest', first)
+ main.sendRequest('updateRequest', second)
+ setCurrentCell(null)
+
+ const updates = events.filter(
+ (event): event is Extract =>
+ event.type === 'updateRequest'
+ )
+ expect(updates.map((event) => [event.data.id, event.data.method])).toEqual([
+ ['concurrent-a', 'A'],
+ ['concurrent-b', 'B']
+ ])
+ await main.dispose()
})
- describe('MainProcess 类', () => {
- describe('构造函数', () => {
- test('当 lock 文件不存在时,创建新的 WebSocket 连接并写入 lock 文件', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 验证 lock 文件被写入
- expect(mockWriteFileSync).toHaveBeenCalledWith(
- expect.stringContaining('test-key'),
- expect.stringContaining(String(process.pid))
- )
-
- // 验证 WebSocket 被创建
- expect(wsInstances.length).toBe(1)
- })
-
- test('WebSocket 连接成功后删除 lock 文件', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 验证 lock 文件被删除
- expect(mockUnlinkSafe).toHaveBeenCalled()
- })
-
- test('当 lock 文件存在且进程存活时,跳过创建并输出警告', async () => {
- mockExistsSync.mockReturnValue(true)
- mockReadFileSync.mockReturnValue('12345')
- mockCheckMainProcessAlive.mockResolvedValue(true)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 运行所有待处理的定时器和微任务
- await vi.runAllTimersAsync()
-
- // 验证警告被输出
- expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('already running'))
- })
-
- test('当 lock 文件存在但进程不存活时,删除 lock 文件并继续', async () => {
- mockExistsSync.mockReturnValue(true)
- mockReadFileSync.mockReturnValue('12345')
- mockCheckMainProcessAlive.mockResolvedValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 运行所有待处理的定时器和微任务
- await vi.runAllTimersAsync()
-
- // 验证 lock 文件被删除
- expect(mockUnlinkSafe).toHaveBeenCalled()
- })
-
- test('WebSocket 连接错误时,启动子进程', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接错误
- wsInstances[0].emit('error', new Error('Connection refused'))
-
- // 验证 fork 被调用
- expect(mockFork).toHaveBeenCalled()
- })
-
- test('子进程发送 READY_MESSAGE 后创建新的 WebSocket 连接', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- const initialWsCount = wsInstances.length
-
- // 模拟 WebSocket 连接错误
- wsInstances[0].emit('error', new Error('Connection refused'))
-
- // 模拟子进程发送 ready 消息
- cpInstances[0].emit('message', READY_MESSAGE)
-
- // 验证新的 WebSocket 连接被创建
- expect(wsInstances.length).toBeGreaterThan(initialWsCount)
- })
-
- test('子进程发送非 READY_MESSAGE 时不创建新连接', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接错误
- wsInstances[0].emit('error', new Error('Connection refused'))
-
- const wsCountAfterError = wsInstances.length
-
- // 模拟子进程发送其他消息
- cpInstances[0].emit('message', 'other-message')
-
- // 验证没有创建新的 WebSocket 连接
- expect(wsInstances.length).toBe(wsCountAfterError)
- })
-
- test('WebSocket 错误事件被正确记录', async () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 先连接成功
- wsInstances[0].emit('open')
-
- // 等待 Promise 解析
- await vi.advanceTimersByTimeAsync(0)
-
- // 然后发生错误
- const error = new Error('WebSocket error')
- wsInstances[0].emit('error', error)
-
- // 验证错误被记录
- expect(consoleSpy).toHaveBeenCalledWith('MainProcess Socket Error: ', error)
-
- consoleSpy.mockRestore()
- })
- })
-
- describe('send 方法', () => {
- test('发送数据到 WebSocket', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 发送数据
- const testData = { type: 'test', data: { foo: 'bar' } }
- await mainProcess.send(testData)
-
- // 验证数据被发送
- expect(mockWsSend).toHaveBeenCalledWith(JSON.stringify(testData))
- })
-
- test('当 cell 被中止时,不发送数据', async () => {
- mockExistsSync.mockReturnValue(false)
- mockGetCurrentCell.mockReturnValue({
- isAborted: true,
- request: new RequestDetail(),
- pipes: []
- })
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 清除之前的调用
- mockWsSend.mockClear()
-
- // 发送数据
- await mainProcess.send({ type: 'test' })
-
- // 验证数据没有被发送
- expect(mockWsSend).not.toHaveBeenCalled()
- })
- })
-
- describe('sendRequest 方法', () => {
- test('发送请求数据', async () => {
- mockExistsSync.mockReturnValue(false)
- mockGetCurrentCell.mockReturnValue(null)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 等待 Promise 解析
- await vi.advanceTimersByTimeAsync(0)
-
- // 清除之前的调用
- mockWsSend.mockClear()
-
- // 发送请求
- const requestDetail = new RequestDetail()
- requestDetail.url = 'http://example.com'
- requestDetail.method = 'GET'
-
- mainProcess.sendRequest('initRequest', requestDetail)
-
- // 等待异步发送完成
- await vi.advanceTimersByTimeAsync(0)
-
- // 验证数据被发送
- expect(mockWsSend).toHaveBeenCalledWith(expect.stringContaining('initRequest'))
- })
-
- test('返回 this 以支持链式调用', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- const requestDetail = new RequestDetail()
- const result = mainProcess.sendRequest('initRequest', requestDetail)
-
- expect(result).toBe(mainProcess)
- })
-
- test('当存在 cell 时,应用 pipes', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const mockPipe = vi.fn((req: RequestDetail) => {
- req.method = 'POST'
- return req
- })
-
- mockGetCurrentCell.mockReturnValue({
- isAborted: false,
- request: new RequestDetail(),
- pipes: [{ type: 'initRequest', pipe: mockPipe }]
- })
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- const requestDetail = new RequestDetail()
- requestDetail.method = 'GET'
-
- mainProcess.sendRequest('initRequest', requestDetail)
-
- // 验证 pipe 被调用
- expect(mockPipe).toHaveBeenCalled()
- })
-
- test('只应用匹配类型的 pipes', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const initPipe = vi.fn((req: RequestDetail) => req)
- const updatePipe = vi.fn((req: RequestDetail) => req)
-
- mockGetCurrentCell.mockReturnValue({
- isAborted: false,
- request: new RequestDetail(),
- pipes: [
- { type: 'initRequest', pipe: initPipe },
- { type: 'updateRequest', pipe: updatePipe }
- ]
- })
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- const requestDetail = new RequestDetail()
- mainProcess.sendRequest('initRequest', requestDetail)
-
- // 验证只有 initPipe 被调用
- expect(initPipe).toHaveBeenCalled()
- expect(updatePipe).not.toHaveBeenCalled()
- })
+ test('strips IncomingMessage/socket state from the WebSocket handshake event', async () => {
+ const { main, events } = bridgeHarness()
+ const response = Object.assign(new EventEmitter(), {
+ httpVersion: '1.1',
+ statusCode: 101,
+ statusMessage: 'Switching Protocols',
+ rawHeaders: ['Upgrade', 'websocket'],
+ headers: { upgrade: 'websocket' },
+ socket: { live: true }
})
- describe('responseRequest 方法', () => {
- test('处理响应数据并发送', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 等待 Promise 解析
- await vi.advanceTimersByTimeAsync(0)
-
- // 清除之前的调用
- mockWsSend.mockClear()
-
- // 创建 mock 响应
- const mockResponse = new EventEmitter() as EventEmitter & {
- statusCode: number
- headers: Record
- }
- mockResponse.statusCode = 200
- mockResponse.headers = { 'content-type': 'application/json' }
-
- // 调用 responseRequest
- mainProcess.responseRequest('test-id', mockResponse as IncomingMessage)
-
- // 模拟响应数据
- mockResponse.emit('data', Buffer.from('{"foo":"bar"}'))
- mockResponse.emit('end')
-
- // 等待异步操作完成
- await vi.advanceTimersByTimeAsync(0)
-
- // 验证数据被发送
- expect(mockWsSend).toHaveBeenCalledWith(expect.stringContaining('responseData'), {
- binary: true
- })
-
- // 验证发送的数据包含正确的 id 和状态码
- const sentData = JSON.parse(mockWsSend.mock.calls[0][0])
- expect(sentData.type).toBe('responseData')
- expect(sentData.data.id).toBe('test-id')
- expect(sentData.data.statusCode).toBe(200)
- })
-
- test('正确合并多个数据块', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 等待 Promise 解析
- await vi.advanceTimersByTimeAsync(0)
-
- // 清除之前的调用
- mockWsSend.mockClear()
-
- const mockResponse = new EventEmitter() as EventEmitter & {
- statusCode: number
- headers: Record
+ await main.send({
+ type: 'Network.webSocketCreated',
+ data: { requestId: 'ws-1', url: 'ws://example.test/', response }
+ } as any)
+
+ expect(events).toEqual([
+ {
+ type: 'Network.webSocketCreated',
+ data: {
+ requestId: 'ws-1',
+ url: 'ws://example.test/',
+ response: {
+ httpVersion: '1.1',
+ statusCode: 101,
+ statusMessage: 'Switching Protocols',
+ rawHeaders: ['Upgrade', 'websocket'],
+ headers: { upgrade: 'websocket' }
+ }
}
- mockResponse.statusCode = 200
- mockResponse.headers = {}
-
- mainProcess.responseRequest('test-id', mockResponse as IncomingMessage)
-
- // 模拟多个数据块
- mockResponse.emit('data', Buffer.from('Hello'))
- mockResponse.emit('data', Buffer.from(' '))
- mockResponse.emit('data', Buffer.from('World'))
- mockResponse.emit('end')
-
- await vi.advanceTimersByTimeAsync(0)
-
- // 验证数据被正确合并
- const sentData = JSON.parse(mockWsSend.mock.calls[0][0])
- const rawData = Buffer.from(sentData.data.rawData.data)
- expect(rawData.toString()).toBe('Hello World')
- })
- })
-
- describe('dispose 方法', () => {
- test('清理 WebSocket 连接', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 调用 dispose
- await mainProcess.dispose()
-
- // 验证 WebSocket 被清理
- expect(mockWsRemoveAllListeners).toHaveBeenCalled()
- expect(mockWsTerminate).toHaveBeenCalled()
- })
-
- test('清理子进程', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- const mainProcess = new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接错误,触发子进程创建
- wsInstances[0].emit('error', new Error('Connection refused'))
-
- // 模拟子进程发送 ready 消息
- cpInstances[0].emit('message', READY_MESSAGE)
-
- // 模拟新的 WebSocket 连接成功
- wsInstances[1].emit('open')
-
- // 调用 dispose
- await mainProcess.dispose()
-
- // 验证子进程被清理
- expect(mockCpRemoveAllListeners).toHaveBeenCalled()
- expect(mockCpKill).toHaveBeenCalled()
- })
- })
-
- describe('healthCheck 私有方法', () => {
- test('WebSocket 连接成功后发送健康检查消息', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 等待 Promise 解析
- await vi.advanceTimersByTimeAsync(0)
-
- // 验证健康检查消息被发送
- expect(mockWsSend).toHaveBeenCalledWith(expect.stringContaining('healthcheck'))
- })
-
- test('定时发送健康检查消息', async () => {
- mockExistsSync.mockReturnValue(false)
-
- const { MainProcess } = await import('./fork')
- new MainProcess({ port: 5270, key: 'test-key' })
-
- // 模拟 WebSocket 连接成功
- wsInstances[0].emit('open')
-
- // 清除初始的健康检查调用
- mockWsSend.mockClear()
-
- // 推进时间 2 秒
- await vi.advanceTimersByTimeAsync(2000)
+ }
+ ])
+ expect((events[0] as any).data.response.socket).toBeUndefined()
+ await main.dispose()
+ })
- // 验证健康检查消息被再次发送
- expect(mockWsSend).toHaveBeenCalledWith(expect.stringContaining('healthcheck'))
- })
+ test('normalizes the historical method/params WebSocket close shape', async () => {
+ const { main, events } = bridgeHarness()
+ await main.send({
+ method: 'Network.webSocketClosed',
+ params: { requestId: 'ws-2', timestamp: 123 }
})
+ expect(events).toEqual([{ type: 'Network.webSocketClosed', data: { requestId: 'ws-2' } }])
+ await main.dispose()
})
- describe('RequestType 类型', () => {
- test('导出正确的请求类型', async () => {
- const { MainProcess } = await import('./fork')
+ test('emits responseReceived immediately and responseData with a real Buffer only on end', async () => {
+ const { main, events } = bridgeHarness()
+ const detail = request('success')
+ main.sendRequest('registerRequest', detail)
+ const response = new FakeResponse()
+
+ main.responseRequest('success', response as any)
+ expect(events.map((event) => event.type)).toEqual(['registerRequest', 'responseReceived'])
+ expect(events[1].data as RequestDetail & { responseStatusText?: string }).toMatchObject({
+ responseStatusCode: 200,
+ responseStatusText: 'OK'
+ })
- // 验证 MainProcess 类存在
- expect(MainProcess).toBeDefined()
- expect(typeof MainProcess).toBe('function')
+ response.emit('data', Buffer.from('hello '))
+ response.emit('data', new Uint8Array(Buffer.from('world')))
+ response.complete = true
+ response.emit('end')
+ response.emit('close')
+
+ expect(events.map((event) => event.type)).toEqual([
+ 'registerRequest',
+ 'responseReceived',
+ 'responseData'
+ ])
+ const result = events[2] as Extract
+ expect(Buffer.isBuffer(result.data.rawData)).toBe(true)
+ expect(result.data.rawData.toString()).toBe('hello world')
+ expect(detail.requestEndTime).toBeGreaterThan(1_000_000_000)
+ expect(detail.requestEndTime).toBeLessThan(10_000_000_000)
+ expect(result.data).toMatchObject({
+ id: 'success',
+ statusCode: 200,
+ statusMessage: 'OK',
+ contentEncoding: 'gzip'
})
+ await main.dispose()
})
- describe('__dirname 导出', () => {
- test('导出 __dirname', async () => {
- const forkModule = await import('./fork')
-
- expect(forkModule.__dirname).toBeDefined()
- expect(typeof forkModule.__dirname).toBe('string')
- })
+ test.each([
+ ['aborted', undefined, true],
+ ['error', new Error('socket reset'), false],
+ ['close', undefined, false]
+ ] as const)(
+ '%s emits requestFailed exactly once and never responseData',
+ async (eventName, error, canceled) => {
+ const { main, events } = bridgeHarness()
+ const detail = request(eventName)
+ main.sendRequest('registerRequest', detail)
+ const response = new FakeResponse()
+ // Real IncomingMessage consumers may keep their own error listener after
+ // MainProcess removes only the listener it owns.
+ response.on('error', () => undefined)
+ main.responseRequest(detail, response as any)
+
+ if (error) response.emit(eventName, error)
+ else response.emit(eventName)
+ response.emit('error', new Error('duplicate'))
+ response.emit('close')
+
+ expect(events.map((event) => event.type)).toEqual([
+ 'registerRequest',
+ 'responseReceived',
+ 'requestFailed'
+ ])
+ const failed = events[2] as Extract
+ expect(failed.data.request.id).toBe(eventName)
+ expect(failed.data.request.requestEndTime).toBeLessThan(10_000_000_000)
+ expect(Boolean(failed.data.canceled)).toBe(canceled)
+ expect(events.some((event) => event.type === 'responseData')).toBe(false)
+ await main.dispose()
+ }
+ )
+
+ test('dispose removes active response listeners and ignores later stream events', async () => {
+ const { main, bridge, events } = bridgeHarness()
+ const detail = request('dispose')
+ main.sendRequest('registerRequest', detail)
+ const response = new FakeResponse()
+ main.responseRequest(detail, response as any)
+
+ await main.dispose()
+ expect(response.listenerCount('data')).toBe(0)
+ expect(response.listenerCount('end')).toBe(0)
+ response.emit('data', Buffer.from('ignored'))
+ response.emit('end')
+ expect(events.map((event) => event.type)).toEqual(['registerRequest', 'responseReceived'])
+ expect(bridge.dispose).toHaveBeenCalledOnce()
})
})
diff --git a/packages/network-debugger/src/core/fork.ts b/packages/network-debugger/src/core/fork.ts
index 13d6c17..c8ead6d 100644
--- a/packages/network-debugger/src/core/fork.ts
+++ b/packages/network-debugger/src/core/fork.ts
@@ -1,190 +1,296 @@
-import { READY_MESSAGE, RequestDetail } from '../common'
-import { type IncomingMessage } from 'http'
-import WebSocket from 'ws'
-import { ChildProcess, fork } from 'child_process'
-import { __dirname } from '../common'
-import { resolve as resolvePath } from 'path'
-import { RegisterOptions } from '../common'
-import fs from 'fs'
-import { sleep, checkMainProcessAlive } from '../utils/process'
-import { unlinkSafe } from '../utils/file'
-import { warn } from '../utils'
-import { getCurrentCell } from './hooks/cell'
-
-class ExpectError extends Error {
- constructor(message: string) {
- super(message)
+import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'
+import type { DevtoolsTarget, Diagnostic } from '../adapters/types'
+import { RequestDetail, type RegisterOptions } from '../common'
+import {
+ LegacyBridgeClient,
+ type LegacyBridgeClientDependencies,
+ type DiagnosticListener,
+ type FailureListener,
+ type LegacyBridgeError
+} from '../legacy-bridge/client'
+import type {
+ LegacyCaptureEvent,
+ LegacyCaptureSink,
+ LegacyRequestEventType,
+ LegacyResponseData,
+ LegacyWebSocketHandshake
+} from '../legacy-bridge/contracts'
+import { getCurrentCell, type Cell } from './hooks/cell'
+
+export type RequestType = LegacyRequestEventType
+
+type CapturedIncomingMessage = NodeJS.ReadableStream & {
+ statusCode?: number
+ statusMessage?: string
+ headers: IncomingHttpHeaders
+ httpVersion?: string
+ rawHeaders?: string[]
+ complete?: boolean
+}
+
+interface LegacyBridgeTransport {
+ readonly ready: Promise
+ send(event: LegacyCaptureEvent): Promise
+ onDiagnostic(listener: DiagnosticListener): () => void
+ onFailure(listener: FailureListener): () => void
+ dispose(): Promise
+}
+
+export interface MainProcessDependencies extends LegacyBridgeClientDependencies {
+ bridge?: LegacyBridgeTransport
+}
+
+function headerValue(headers: IncomingHttpHeaders, name: string): string | undefined {
+ const value = headers[name]
+ if (Array.isArray(value)) return value[0]
+ return value === undefined ? undefined : String(value)
+}
+
+function websocketHandshake(response: unknown): LegacyWebSocketHandshake {
+ const value = (response ?? {}) as Partial & Partial
+ return {
+ httpVersion: typeof value.httpVersion === 'string' ? value.httpVersion : '',
+ statusCode: typeof value.statusCode === 'number' ? value.statusCode : 0,
+ statusMessage: typeof value.statusMessage === 'string' ? value.statusMessage : '',
+ rawHeaders: Array.isArray(value.rawHeaders) ? [...value.rawHeaders] : [],
+ headers: value.headers && typeof value.headers === 'object' ? { ...value.headers } : {}
}
}
/**
- * @flow initRequest -> registerRequest -> updateRequest -> endRequest
+ * Convert the handful of historical capture call shapes at the compatibility
+ * boundary, and guarantee that sockets/IncomingMessage objects never cross IPC.
*/
-export type RequestType = 'initRequest' | 'registerRequest' | 'updateRequest' | 'endRequest'
-
-export class MainProcess {
- private ws: Promise
- private options: RegisterOptions
- private cp?: ChildProcess
-
- constructor(props: RegisterOptions & { key: string }) {
- this.options = props
- this.ws = new Promise(async (resolve, reject) => {
- const lockFilePath = resolvePath(__dirname, `./${props.key}`)
- if (fs.existsSync(lockFilePath)) {
- // 读取 lock 文件中的进程号
- const pid = fs.readFileSync(lockFilePath, 'utf-8')
- await sleep(1)
-
- // 检测该进程是否存活且 port 是否被占用
- const isProcessAlice = await checkMainProcessAlive(pid, props.port!)
- if (isProcessAlice) {
- warn(`The main process with same options is already running, skip it.`)
- return
- }
- // 如果进程不存在:
- // 1. 热更新导致 process 重启
- // 2. 上一个进程未成功删除 lock
- // 都应该继续往下
- unlinkSafe(lockFilePath)
- }
- fs.writeFileSync(lockFilePath, `${process.pid}`)
- const socket = new WebSocket(`ws://127.0.0.1:${props.port}`)
- socket.on('open', () => {
- unlinkSafe(lockFilePath)
- resolve(socket)
- })
- socket.on('error', () => {
- this.openProcess(() => {
- unlinkSafe(lockFilePath)
- const socket = new WebSocket(`ws://127.0.0.1:${props.port}`)
- socket.on('open', () => {
- resolve(socket)
- })
- socket.on('error', reject)
- })
- })
- })
- this.ws
- .then((ws) => {
- this.healthCheck()
- ws.on('error', (e) => {
- console.error('MainProcess Socket Error: ', e)
- })
- })
- .catch((e) => {
- if (e instanceof ExpectError) {
- return
- }
- throw e
- })
+function normalizeCaptureEvent(input: unknown): LegacyCaptureEvent | undefined {
+ if (!input || typeof input !== 'object') return undefined
+ const value = input as Record
+
+ // v1 accidentally used CDP's method/params shape for this one event.
+ if (value.method === 'Network.webSocketClosed') {
+ const params = (value.params ?? {}) as { requestId?: unknown }
+ if (typeof params.requestId !== 'string') return undefined
+ return {
+ type: 'Network.webSocketClosed',
+ data: { requestId: params.requestId }
+ }
}
- private openProcess(callback?: (cp: ChildProcess) => void) {
- const forkProcess = () => {
- // fork a new process with options
- const cp = fork(resolvePath(__dirname, './fork'), {
- env: {
- ...process.env,
- NETWORK_OPTIONS: JSON.stringify(this.options)
- }
- })
- const handleMsg = (e: any) => {
- if (e === READY_MESSAGE) {
- callback && callback(cp)
- cp.off('message', handleMsg)
- }
+ if (typeof value.type !== 'string' || !('data' in value)) return undefined
+ if (value.type === 'Network.webSocketCreated') {
+ const data = value.data as Record
+ if (!data || typeof data.requestId !== 'string' || typeof data.url !== 'string') {
+ return undefined
+ }
+ return {
+ type: 'Network.webSocketCreated',
+ data: {
+ requestId: data.requestId,
+ url: data.url,
+ ...(data.initiator ? { initiator: data.initiator as RequestDetail['initiator'] } : {}),
+ response: websocketHandshake(data.response)
}
-
- cp.on('message', handleMsg)
- this.cp = cp
}
+ }
+
+ return input as LegacyCaptureEvent
+}
+
+function requestForId(id: string, known?: RequestDetail): RequestDetail {
+ if (known) return known
+ const request = new RequestDetail()
+ request.id = id
+ return request
+}
+
+/**
+ * Compatibility facade used by the existing HTTP/fetch capture patches.
+ * Application-to-child transport is process IPC with advanced serialization.
+ */
+export class MainProcess implements LegacyCaptureSink {
+ readonly ready: Promise
+
+ private readonly bridge: LegacyBridgeTransport
+ private readonly requests = new Map()
+ private readonly requestCells = new Map()
+ private readonly responseCleanups = new Set<() => void>()
+ private disposed = false
- forkProcess()
+ constructor(
+ props: RegisterOptions & { key: string },
+ dependencies: MainProcessDependencies = {}
+ ) {
+ this.bridge =
+ dependencies.bridge ??
+ new LegacyBridgeClient(
+ {
+ host: '127.0.0.1',
+ targetPort: props.serverPort ?? 0,
+ title: 'Node Network Devtools (Legacy)'
+ },
+ dependencies
+ )
+ this.ready = this.bridge.ready
}
- public async send(data: any) {
- const currentCell = getCurrentCell()
- if (currentCell?.isAborted) {
- return
+ onDiagnostic(listener: (diagnostic: Diagnostic) => void): () => void {
+ return this.bridge.onDiagnostic(listener)
+ }
+
+ onFailure(listener: (error: LegacyBridgeError) => void): () => void {
+ return this.bridge.onFailure(listener)
+ }
+
+ public send(event: LegacyCaptureEvent): Promise
+ public send(event: unknown): Promise
+ public send(event: unknown): Promise {
+ if (this.disposed) return Promise.resolve()
+ const normalized = normalizeCaptureEvent(event)
+ if (!normalized) return Promise.resolve()
+ const sendPromise = this.bridge.send(normalized)
+ const terminalRequestId =
+ normalized.type === 'requestFailed'
+ ? normalized.data.request.id
+ : normalized.type === 'Network.webSocketClosed'
+ ? normalized.data.requestId
+ : undefined
+ if (terminalRequestId) {
+ this.requests.delete(terminalRequestId)
+ this.requestCells.delete(terminalRequestId)
}
- const ws = await this.ws.catch((err) => {
- if (err instanceof ExpectError) {
- return null
- }
- throw err
- })
- if (!ws) return
- ws.send(JSON.stringify(data))
+ return sendPromise
}
- public sendRequest(type: RequestType, request: RequestDetail) {
- const currentCell = getCurrentCell()
- let req = request
+ public sendRequest(type: RequestType, request: RequestDetail): this {
+ const requestId = request.id
+ let currentCell = this.requestCells.get(requestId)
+ if ((type === 'initRequest' || type === 'registerRequest') && !currentCell) {
+ const candidate = getCurrentCell()
+ if (candidate?.request.id === requestId) {
+ currentCell = candidate
+ this.requestCells.set(requestId, candidate)
+ }
+ }
+
+ if (currentCell?.isAborted) {
+ if (type === 'endRequest') this.requestCells.delete(requestId)
+ return this
+ }
+
+ let transformed = request
if (currentCell) {
- currentCell.request = req
- const pipes = currentCell.pipes.filter((p) => p.type === type).map((p) => p.pipe)
- pipes.forEach((pipe) => {
- req = pipe(req)
- })
- currentCell.request = req
+ currentCell.request = transformed
+ const pipes = currentCell.pipes.filter((pipe) => pipe.type === type)
+ for (const { pipe } of pipes) transformed = pipe(transformed)
+ currentCell.request = transformed
}
- this.send({
- type,
- data: request
- })
+ if (transformed.id !== requestId && currentCell) {
+ this.requestCells.delete(requestId)
+ this.requestCells.set(transformed.id, currentCell)
+ }
+ this.requests.set(transformed.id, transformed)
+ void this.send({ type, data: transformed })
+ if (type === 'endRequest') {
+ this.requests.delete(transformed.id)
+ this.requestCells.delete(transformed.id)
+ }
return this
}
- private async healthCheck() {
- const ws = await this.ws
- const ping = () => {
- ws.send(
- JSON.stringify({
- type: 'healthcheck',
- data: {}
- })
- )
- }
- ping()
- setInterval(ping, 2000)
- }
+ public responseRequest(id: string, response: CapturedIncomingMessage): void
+ public responseRequest(request: RequestDetail, response: CapturedIncomingMessage): void
+ public responseRequest(
+ idOrRequest: string | RequestDetail,
+ response: CapturedIncomingMessage
+ ): void {
+ if (this.disposed) return
+ const id = typeof idOrRequest === 'string' ? idOrRequest : idOrRequest.id
+ const request = requestForId(
+ id,
+ typeof idOrRequest === 'string' ? this.requests.get(id) : idOrRequest
+ )
+ request.responseHeaders = response.headers ?? request.responseHeaders ?? {}
+ request.responseStatusCode = response.statusCode ?? request.responseStatusCode ?? 0
+ ;(request as RequestDetail & { responseStatusText?: string }).responseStatusText =
+ response.statusMessage
+ this.requests.set(id, request)
+
+ // responseReceived is emitted as soon as headers arrive. Body completion is
+ // deliberately separate so failures never produce loadingFinished.
+ void this.send({ type: 'responseReceived', data: request })
- public responseRequest(id: string, response: IncomingMessage) {
- const responseBuffer: Buffer[] = []
-
- response.on('data', (chunk: any) => {
- responseBuffer.push(chunk)
- })
-
- response.on('end', () => {
- const rawData = Buffer.concat(responseBuffer)
- this.ws.then((ws) => {
- ws.send(
- JSON.stringify({
- type: 'responseData',
- data: {
- id: id,
- rawData: rawData,
- statusCode: response.statusCode,
- headers: response.headers
- }
- }),
- { binary: true }
- )
+ const chunks: Buffer[] = []
+ let settled = false
+
+ const cleanup = () => {
+ response.off('data', onData)
+ response.off('end', onEnd)
+ response.off('aborted', onAborted)
+ response.off('error', onError)
+ response.off('close', onClose)
+ this.responseCleanups.delete(cleanup)
+ this.requests.delete(id)
+ this.requestCells.delete(id)
+ }
+ const fail = (errorText: string, canceled = false) => {
+ if (settled || this.disposed) return
+ settled = true
+ request.requestEndTime = Date.now() / 1_000
+ cleanup()
+ void this.send({
+ type: 'requestFailed',
+ data: {
+ request,
+ errorText,
+ ...(canceled ? { canceled: true } : {})
+ }
})
- })
+ }
+ const onData = (chunk: unknown) => {
+ if (settled) return
+ if (Buffer.isBuffer(chunk)) chunks.push(chunk)
+ else if (chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk))
+ else chunks.push(Buffer.from(String(chunk)))
+ }
+ const onEnd = () => {
+ if (settled || this.disposed) return
+ settled = true
+ const rawData = Buffer.concat(chunks)
+ request.requestEndTime = Date.now() / 1_000
+ const data: LegacyResponseData = {
+ id,
+ rawData,
+ statusCode: response.statusCode ?? request.responseStatusCode ?? 0,
+ ...(response.statusMessage ? { statusMessage: response.statusMessage } : {}),
+ headers: response.headers ?? request.responseHeaders ?? {},
+ ...(headerValue(response.headers ?? {}, 'content-encoding')
+ ? { contentEncoding: headerValue(response.headers ?? {}, 'content-encoding') }
+ : {})
+ }
+ cleanup()
+ void this.send({ type: 'responseData', data })
+ }
+ const onAborted = () => fail('The response was aborted.', true)
+ const onError = (error: unknown) => fail(error instanceof Error ? error.message : String(error))
+ const onClose = () => {
+ if (!settled) fail('The response stream closed before completion.')
+ }
+
+ response.on('data', onData)
+ response.once('end', onEnd)
+ response.once('aborted', onAborted)
+ response.once('error', onError)
+ response.once('close', onClose)
+ this.responseCleanups.add(cleanup)
}
- public async dispose() {
- const ws = await this.ws
- ws.removeAllListeners()
- ws.terminate()
- if (!this.cp) return
- this.cp.removeAllListeners()
- this.cp.kill()
- this.cp = void 0
+ public async dispose(): Promise {
+ if (this.disposed) return
+ this.disposed = true
+ for (const cleanup of [...this.responseCleanups]) cleanup()
+ this.responseCleanups.clear()
+ this.requests.clear()
+ this.requestCells.clear()
+ await this.bridge.dispose()
}
}
-export { __dirname }
diff --git a/packages/network-debugger/src/core/index.test.ts b/packages/network-debugger/src/core/index.test.ts
index 4def64b..1eaf838 100644
--- a/packages/network-debugger/src/core/index.test.ts
+++ b/packages/network-debugger/src/core/index.test.ts
@@ -1,372 +1,577 @@
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import http from 'http'
import https from 'https'
+import type {
+ AdapterProbe,
+ AdapterSession,
+ CapabilityMap,
+ DevtoolsTarget,
+ Diagnostic
+} from '../adapters/types'
+
+const mocks = vi.hoisted(() => ({
+ mainProcessConstructorCalls: [] as Record[],
+ mainProcessDispose: vi.fn<() => Promise>(),
+ proxyFetch: vi.fn(),
+ unsetFetch: vi.fn(),
+ requestProxyFactory: vi.fn(),
+ getProxyFactory: vi.fn(),
+ undiciFetchProxy: vi.fn(),
+ unsetUndiciFetch: vi.fn(),
+ generateHash: vi.fn(),
+ nativeProbe: vi.fn(),
+ nativeStart: vi.fn(),
+ openDevtoolsTarget: vi.fn<() => Promise>(),
+ sessionRecorderStart: vi.fn(),
+ sessionRecorderClose: vi.fn<() => Promise>(),
+ exportHar: vi.fn<() => Promise>()
+}))
-// 使用 vi.hoisted 确保变量在 mock 提升时可用
-const { mainProcessConstructorCalls, mockDispose, mockSendRequest, mockSend } = vi.hoisted(() => {
- return {
- mainProcessConstructorCalls: [] as Record[],
- mockDispose: vi.fn(),
- mockSendRequest: vi.fn(),
- mockSend: vi.fn()
- }
-})
+vi.mock('./fork', () => {
+ class MainProcess {
+ readonly ready: Promise
+
+ constructor(readonly options: Record) {
+ mocks.mainProcessConstructorCalls.push(options)
+ const port = Number(options.serverPort) || 49_152
+ const id = 'node-network-devtools-legacy-test'
+ const authority = `127.0.0.1:${port}`
+ this.ready = Promise.resolve({
+ id,
+ title: 'Node Network Devtools (Legacy)',
+ type: 'node',
+ url: '',
+ webSocketDebuggerUrl: `ws://${authority}/devtools/page/${id}`,
+ devtoolsFrontendUrl: `devtools://devtools/bundled/js_app.html?ws=${authority}/devtools/page/${id}`,
+ discoveryUrl: `http://${authority}/json/list`
+ })
+ }
-// Mock MainProcess 类型定义
-interface MockMainProcessInstance {
- props: Record
- sendRequest: () => MockMainProcessInstance
- send: () => void
- dispose: () => void
-}
+ send() {}
-// Mock 依赖模块 - 使用函数声明而不是类表达式
-vi.mock('./fork', () => {
- // 使用函数构造器模式
- function MainProcess(this: MockMainProcessInstance, props: Record) {
- this.props = props
- mainProcessConstructorCalls.push(props)
- const self = this
- this.sendRequest = function (): MockMainProcessInstance {
- mockSendRequest()
- return self
+ sendRequest() {
+ return this
}
- this.send = function (): void {
- mockSend()
+
+ responseRequest() {}
+
+ dispose() {
+ return mocks.mainProcessDispose()
}
- this.dispose = function (): void {
- mockDispose()
+
+ onDiagnostic() {
+ return () => undefined
}
- }
- return {
- __esModule: true,
- MainProcess
+ onFailure() {
+ return () => undefined
+ }
}
-})
-vi.mock('./fetch', () => ({
- __esModule: true,
- proxyFetch: vi.fn().mockReturnValue(() => {})
-}))
+ return { MainProcess }
+})
+vi.mock('./fetch', () => ({ proxyFetch: mocks.proxyFetch }))
vi.mock('./request', () => ({
- __esModule: true,
- requestProxyFactory: vi.fn().mockReturnValue(() => {})
+ requestProxyFactory: mocks.requestProxyFactory,
+ getProxyFactory: mocks.getProxyFactory
}))
-
-vi.mock('./undici', () => ({
- __esModule: true,
- undiciFetchProxy: vi.fn().mockReturnValue(() => {})
+vi.mock('./undici', () => ({ undiciFetchProxy: mocks.undiciFetchProxy }))
+vi.mock('../utils', () => ({ generateHash: mocks.generateHash }))
+vi.mock('../target/frontend-launcher', () => ({
+ openDevtoolsTarget: mocks.openDevtoolsTarget
}))
-
-vi.mock('../utils', () => ({
- __esModule: true,
- generateHash: vi.fn().mockReturnValue('mock-hash-key')
+vi.mock('../session', () => ({
+ SessionRecorder: { start: mocks.sessionRecorderStart },
+ exportHar: mocks.exportHar
+}))
+vi.mock('../adapters/node-native', () => ({
+ NodeNativeAdapterError: class NodeNativeAdapterError extends Error {
+ readonly code: string
+ readonly hint?: string
+ readonly diagnostics: readonly Diagnostic[]
+
+ constructor(diagnostic: Diagnostic, diagnostics: readonly Diagnostic[]) {
+ super(diagnostic.message)
+ this.name = 'NodeNativeAdapterError'
+ this.code = diagnostic.code
+ this.hint = diagnostic.hint
+ this.diagnostics = diagnostics
+ }
+ },
+ NodeNativeAdapter: class {
+ readonly kind = 'native' as const
+ probe = mocks.nativeProbe
+ start = mocks.nativeStart
+ }
}))
-// 导入被测模块和 mock 模块
import { register } from './index'
-import { proxyFetch } from './fetch'
-import { requestProxyFactory } from './request'
-import { undiciFetchProxy } from './undici'
-import { generateHash } from '../utils'
+import { disposeActiveRegistration, RuntimeRegistrationError } from '../runtime/controller'
+import { LEGACY_CAPABILITIES } from '../adapters/legacy'
+
+const NATIVE_CAPABILITIES: CapabilityMap = Object.freeze({
+ http: true,
+ https: true,
+ fetch: true,
+ http2: true,
+ responseBody: true,
+ requestBody: true,
+ websocketLifecycle: true,
+ websocketFrames: true,
+ sseMessages: false,
+ initiator: true
+})
+
+const EMPTY_CAPABILITIES: CapabilityMap = Object.freeze({
+ http: false,
+ https: false,
+ fetch: false,
+ http2: false,
+ responseBody: false,
+ requestBody: false,
+ websocketLifecycle: false,
+ websocketFrames: false,
+ sseMessages: false,
+ initiator: false
+})
+
+const NATIVE_TARGET: DevtoolsTarget = Object.freeze({
+ id: 'native-target',
+ title: 'Node.js',
+ type: 'node',
+ url: 'file:///fixture.js',
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9229/native-target',
+ devtoolsFrontendUrl: 'devtools://native-target',
+ discoveryUrl: 'http://127.0.0.1:9229/json/list'
+})
+
+const nativeUnavailableDiagnostic: Diagnostic = Object.freeze({
+ code: 'NND_NATIVE_FLAG_REQUIRED',
+ level: 'error',
+ message: 'Native network inspection requires --experimental-network-inspection.',
+ hint: 'Restart with: node --inspect=0 --experimental-network-inspection '
+})
+
+const nativeUnavailableProbe = (): AdapterProbe => ({
+ kind: 'native',
+ available: false,
+ autoSelectable: false,
+ capabilities: EMPTY_CAPABILITIES,
+ diagnostics: [nativeUnavailableDiagnostic]
+})
-describe('core/index.ts', () => {
+const nativeAvailableProbe = (): AdapterProbe => ({
+ kind: 'native',
+ available: true,
+ autoSelectable: true,
+ capabilities: NATIVE_CAPABILITIES,
+ diagnostics: []
+})
+
+const nativeSession = (dispose = vi.fn<() => Promise>()): AdapterSession => ({
+ kind: 'native',
+ capabilities: NATIVE_CAPABILITIES,
+ target: NATIVE_TARGET,
+ diagnostics: [],
+ dispose
+})
+
+describe('core register compatibility API', () => {
let originalHttpRequest: typeof http.request
let originalHttpsRequest: typeof https.request
+ let originalHttpGet: typeof http.get
+ let originalHttpsGet: typeof https.get
- beforeEach(() => {
+ beforeEach(async () => {
+ await disposeActiveRegistration()
vi.clearAllMocks()
- mainProcessConstructorCalls.length = 0
- // 保存原始的 request 方法
+ mocks.mainProcessConstructorCalls.length = 0
originalHttpRequest = http.request
originalHttpsRequest = https.request
+ originalHttpGet = http.get
+ originalHttpsGet = https.get
+
+ mocks.mainProcessDispose.mockResolvedValue(undefined)
+ mocks.proxyFetch.mockReturnValue(mocks.unsetFetch)
+ mocks.requestProxyFactory.mockImplementation(() => vi.fn())
+ mocks.getProxyFactory.mockImplementation(() => vi.fn())
+ mocks.undiciFetchProxy.mockReturnValue(mocks.unsetUndiciFetch)
+ mocks.generateHash.mockReturnValue('mock-hash-key')
+ mocks.nativeProbe.mockImplementation(nativeUnavailableProbe)
+ mocks.nativeStart.mockImplementation(async () => nativeSession())
+ mocks.openDevtoolsTarget.mockResolvedValue(undefined)
+ mocks.sessionRecorderClose.mockResolvedValue(undefined)
+ mocks.sessionRecorderStart.mockResolvedValue({
+ directory: '/recordings/session-1',
+ getManifest: () => ({ sessionId: 'session-1' }),
+ close: mocks.sessionRecorderClose
+ })
+ mocks.exportHar.mockResolvedValue({})
})
- afterEach(() => {
- // 恢复原始的 request 方法
+ afterEach(async () => {
+ await disposeActiveRegistration()
http.request = originalHttpRequest
https.request = originalHttpsRequest
+ http.get = originalHttpGet
+ https.get = originalHttpsGet
})
- describe('register 函数', () => {
- describe('默认配置', () => {
- test('不传参数时使用默认配置', () => {
- const unregister = register()
-
- expect(mainProcessConstructorCalls).toHaveLength(1)
- expect(mainProcessConstructorCalls[0]).toEqual({
- port: 5270,
- serverPort: 5271,
- autoOpenDevtool: true,
- key: 'mock-hash-key'
- })
-
- // 清理
- if (unregister) unregister()
- })
-
- test('默认拦截 fetch', () => {
- const unregister = register()
-
- expect(proxyFetch).toHaveBeenCalled()
-
- if (unregister) unregister()
- })
-
- test('默认拦截 http/https', () => {
- const unregister = register()
-
- expect(requestProxyFactory).toHaveBeenCalledTimes(2)
-
- if (unregister) unregister()
- })
-
- test('默认不拦截 undici', () => {
- const unregister = register()
-
- expect(undiciFetchProxy).not.toHaveBeenCalled()
-
- if (unregister) unregister()
- })
+ test('register returns a callable handle with an observable ready lifecycle', async () => {
+ const handle = register({ mode: 'legacy' })
+
+ expect(typeof handle).toBe('function')
+ expect(handle.ready).toBeInstanceOf(Promise)
+ expect(handle.status()).toEqual({ state: 'starting' })
+ expect(typeof handle.dispose).toBe('function')
+ expect(typeof handle.openDevtools).toBe('function')
+ expect(typeof handle.on).toBe('function')
+
+ const ready = await handle.ready
+
+ expect(handle.status()).toEqual({ state: 'ready', mode: 'legacy' })
+ expect(ready).toEqual({
+ mode: 'legacy',
+ target: {
+ id: 'node-network-devtools-legacy-test',
+ title: 'Node Network Devtools (Legacy)',
+ type: 'node',
+ url: '',
+ webSocketDebuggerUrl:
+ 'ws://127.0.0.1:49152/devtools/page/node-network-devtools-legacy-test',
+ devtoolsFrontendUrl:
+ 'devtools://devtools/bundled/js_app.html?ws=127.0.0.1:49152/devtools/page/node-network-devtools-legacy-test',
+ discoveryUrl: 'http://127.0.0.1:49152/json/list'
+ },
+ capabilities: LEGACY_CAPABILITIES,
+ diagnostics: [],
+ fallbackReason: undefined
})
+ })
- describe('自定义配置', () => {
- test('自定义端口配置', () => {
- const unregister = register({
- port: 8080,
- serverPort: 8081
- })
-
- expect(mainProcessConstructorCalls).toHaveLength(1)
- expect(mainProcessConstructorCalls[0]).toMatchObject({
- port: 8080,
- serverPort: 8081
- })
-
- if (unregister) unregister()
- })
+ test('ready exposes the native mode, target, and capabilities', async () => {
+ const disposeNative = vi.fn<() => Promise>().mockResolvedValue(undefined)
+ const session = nativeSession(disposeNative)
+ mocks.nativeProbe.mockImplementation(nativeAvailableProbe)
+ mocks.nativeStart.mockResolvedValue(session)
- test('禁用自动打开 DevTools', () => {
- const unregister = register({
- autoOpenDevtool: false
- })
+ const handle = register({
+ mode: 'native',
+ requiredCapabilities: ['http', 'fetch', 'http2']
+ })
- expect(mainProcessConstructorCalls).toHaveLength(1)
- expect(mainProcessConstructorCalls[0]).toMatchObject({
- autoOpenDevtool: false
- })
+ await expect(handle.ready).resolves.toEqual({
+ mode: 'native',
+ target: NATIVE_TARGET,
+ capabilities: NATIVE_CAPABILITIES,
+ diagnostics: [],
+ fallbackReason: undefined
+ })
+ expect(mocks.nativeStart).toHaveBeenCalledWith({
+ requiredCapabilities: ['http', 'fetch', 'http2'],
+ inspector: { host: '127.0.0.1', port: 0 }
+ })
+ expect(mocks.mainProcessConstructorCalls).toHaveLength(0)
- if (unregister) unregister()
- })
+ await handle.dispose()
+ expect(disposeNative).toHaveBeenCalledOnce()
+ })
- test('禁用 fetch 拦截', () => {
- const unregister = register({
- intercept: {
- fetch: false
- }
- })
+ test('auto exposes a structured fallback when native is unavailable', async () => {
+ const handle = register({
+ mode: 'auto',
+ requiredCapabilities: ['fetch', 'responseBody']
+ })
- expect(proxyFetch).not.toHaveBeenCalled()
+ const ready = await handle.ready
+
+ expect(ready.mode).toBe('legacy')
+ expect(ready.capabilities).toEqual(LEGACY_CAPABILITIES)
+ expect(ready.fallbackReason).toEqual({
+ code: 'NND_AUTO_FALLBACK',
+ level: 'warn',
+ message: 'Native adapter cannot satisfy this selection; using legacy adapter.',
+ hint: 'Use mode "native" to fail instead of falling back.',
+ details: {
+ from: 'native',
+ to: 'legacy',
+ reason: 'unavailable',
+ requiredCapabilities: ['fetch', 'responseBody'],
+ diagnosticCodes: ['NND_NATIVE_FLAG_REQUIRED'],
+ diagnostics: [nativeUnavailableDiagnostic]
+ }
+ })
+ expect(ready.diagnostics).toEqual([ready.fallbackReason])
+ expect(mocks.nativeStart).not.toHaveBeenCalled()
+ })
- if (unregister) unregister()
- })
+ test('forced native publishes the actionable Native error and releases active state', async () => {
+ const failed = register({ mode: 'native' })
- test('禁用 http/https 拦截', () => {
- const unregister = register({
- intercept: {
- normal: false
- }
- })
+ await expect(failed.ready).rejects.toMatchObject({
+ name: 'NodeNativeAdapterError',
+ code: 'NND_NATIVE_FLAG_REQUIRED',
+ message: 'Native network inspection requires --experimental-network-inspection.',
+ hint: 'Restart with: node --inspect=0 --experimental-network-inspection '
+ })
+ expect(failed.status()).toMatchObject({ state: 'failed' })
- expect(requestProxyFactory).not.toHaveBeenCalled()
+ await failed.dispose()
+ expect(failed.status()).toEqual({ state: 'disposed', mode: undefined })
- if (unregister) unregister()
- })
+ const replacement = register({ mode: 'legacy' })
+ await expect(replacement.ready).resolves.toMatchObject({ mode: 'legacy' })
+ })
- test('启用 undici fetch 拦截', () => {
- const unregister = register({
- intercept: {
- undici: {
- fetch: true
+ test('rejects Native plus Mock synchronously with a stable capability conflict', () => {
+ expect(() =>
+ register({
+ mode: 'native',
+ legacy: {
+ mock: [
+ {
+ match: { url: 'https://example.test/*' },
+ response: { status: 200, body: 'mocked' }
}
- }
- })
-
- expect(undiciFetchProxy).toHaveBeenCalled()
-
- if (unregister) unregister()
+ ]
+ }
})
-
- test('undici 配置为 false 时不拦截', () => {
- const unregister = register({
- intercept: {
- undici: false
- }
- })
-
- expect(undiciFetchProxy).not.toHaveBeenCalled()
-
- if (unregister) unregister()
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'RuntimeRegistrationError',
+ code: 'NND_NATIVE_MOCK_CONFLICT',
+ message: 'Request/response mocking is available only with the Legacy backend.'
})
+ )
+ expect(mocks.nativeStart).not.toHaveBeenCalled()
+ expect(mocks.mainProcessConstructorCalls).toHaveLength(0)
+ })
- test('undici.fetch 为 false 时不拦截', () => {
- const unregister = register({
- intercept: {
- undici: {
- fetch: false
- }
- }
- })
-
- expect(undiciFetchProxy).not.toHaveBeenCalled()
-
- if (unregister) unregister()
- })
+ test('Auto selects Legacy and exposes a structured reason when Mock is configured', async () => {
+ mocks.nativeProbe.mockImplementation(nativeAvailableProbe)
+ const mock = [
+ {
+ id: 'fixture',
+ match: { url: 'https://example.test/*' },
+ response: { status: 200, body: 'mocked' }
+ }
+ ] as const
+
+ const handle = register({ mode: 'auto', legacy: { mock } })
+ const ready = await handle.ready
+
+ expect(ready.mode).toBe('legacy')
+ expect(ready.fallbackReason).toEqual({
+ code: 'NND_AUTO_LEGACY_MOCK_REQUIRED',
+ level: 'info',
+ message: 'Auto selected Legacy because request/response mocking was configured.',
+ hint: 'Remove legacy.mock to allow Auto to select the Native backend.'
})
+ expect(ready.diagnostics).toContainEqual(ready.fallbackReason)
+ expect(mocks.nativeStart).not.toHaveBeenCalled()
+ expect(mocks.proxyFetch).toHaveBeenCalledWith(expect.anything(), mock)
+ })
- describe('generateHash 调用', () => {
- test('使用配置生成 hash key', () => {
- const unregister = register({
- port: 3000,
- serverPort: 3001,
- autoOpenDevtool: false
- })
-
- expect(generateHash).toHaveBeenCalledWith(
- JSON.stringify({
- port: 3000,
- serverPort: 3001,
- autoOpenDevtool: false
- })
- )
-
- if (unregister) unregister()
- })
+ test('records a Session for either backend and exports HAR before backend disposal', async () => {
+ const handle = register({
+ mode: 'legacy',
+ session: {
+ directory: '/recordings/session-1',
+ bodyCommandTimeoutMs: 2500,
+ har: '/recordings/session-1.har'
+ }
})
- describe('http/https 请求代理', () => {
- test('http.request 被替换为代理函数', () => {
- const originalRequest = http.request
- const unregister = register()
-
- expect(http.request).not.toBe(originalRequest)
- expect(requestProxyFactory).toHaveBeenCalledWith(originalRequest, false, expect.anything())
-
- if (unregister) unregister()
- })
-
- test('https.request 被替换为代理函数', () => {
- const originalRequest = https.request
- const unregister = register()
-
- expect(https.request).not.toBe(originalRequest)
- expect(requestProxyFactory).toHaveBeenCalledWith(originalRequest, true, expect.anything())
-
- if (unregister) unregister()
- })
+ const ready = await handle.ready
+ expect(mocks.sessionRecorderStart).toHaveBeenCalledWith({
+ directory: '/recordings/session-1',
+ target: ready.target,
+ bodyCommandTimeoutMs: 2500
})
+ expect(ready.session).toEqual({
+ directory: '/recordings/session-1',
+ sessionId: 'session-1'
+ })
+ expect(ready.diagnostics).toContainEqual(
+ expect.objectContaining({ code: 'NND_SESSION_RECORDING_STARTED' })
+ )
+
+ await handle.dispose()
+ expect(mocks.sessionRecorderClose).toHaveBeenCalledOnce()
+ expect(mocks.exportHar).toHaveBeenCalledWith(
+ '/recordings/session-1',
+ '/recordings/session-1.har'
+ )
+ expect(mocks.sessionRecorderClose.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.exportHar.mock.invocationCallOrder[0]
+ )
+ expect(mocks.exportHar.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.mainProcessDispose.mock.invocationCallOrder[0]
+ )
+ })
- describe('unregister 函数', () => {
- test('返回 unregister 函数', () => {
- const unregister = register()
-
- expect(typeof unregister).toBe('function')
-
- if (unregister) unregister()
- })
-
- test('调用 unregister 后恢复 http.request', () => {
- const originalRequest = http.request
- const unregister = register()
+ test('releases the backend when Session startup fails', async () => {
+ mocks.sessionRecorderStart.mockRejectedValueOnce(new Error('recording directory exists'))
+ const handle = register({
+ mode: 'legacy',
+ session: { directory: '/recordings/existing' }
+ })
- expect(http.request).not.toBe(originalRequest)
+ await expect(handle.ready).rejects.toThrow('recording directory exists')
+ expect(mocks.mainProcessDispose).toHaveBeenCalledOnce()
+ })
- if (unregister) unregister()
+ test('explicit legacy activates old capture options and restores every patch', async () => {
+ const handle = register({
+ mode: 'legacy',
+ port: 8080,
+ serverPort: 8081,
+ autoOpenDevtool: true,
+ intercept: {
+ fetch: true,
+ normal: true,
+ undici: { fetch: true }
+ }
+ })
- expect(http.request).toBe(originalRequest)
- })
+ const ready = await handle.ready
+
+ expect(new URL(ready.target.webSocketDebuggerUrl).port).toBe('8081')
+ expect(ready.target.discoveryUrl).toBe('http://127.0.0.1:8081/json/list')
+
+ expect(mocks.generateHash).toHaveBeenCalledWith(
+ JSON.stringify({ port: 8080, serverPort: 8081, autoOpenDevtool: false })
+ )
+ expect(mocks.mainProcessConstructorCalls).toEqual([
+ {
+ port: 8080,
+ serverPort: 8081,
+ autoOpenDevtool: false,
+ key: 'mock-hash-key'
+ }
+ ])
+ expect(mocks.proxyFetch).toHaveBeenCalledOnce()
+ expect(mocks.requestProxyFactory).toHaveBeenCalledTimes(2)
+ expect(mocks.requestProxyFactory).toHaveBeenNthCalledWith(
+ 1,
+ originalHttpRequest,
+ false,
+ expect.anything()
+ )
+ expect(mocks.requestProxyFactory).toHaveBeenNthCalledWith(
+ 2,
+ originalHttpsRequest,
+ true,
+ expect.anything()
+ )
+ expect(mocks.undiciFetchProxy).toHaveBeenCalledOnce()
+ expect(http.request).not.toBe(originalHttpRequest)
+ expect(https.request).not.toBe(originalHttpsRequest)
+ expect(mocks.openDevtoolsTarget).toHaveBeenCalledOnce()
+ expect(mocks.openDevtoolsTarget).toHaveBeenCalledWith(ready.target)
+ expect(ready.diagnostics).toContainEqual({
+ code: 'NND_LEGACY_OPTIONS_DEPRECATED',
+ level: 'warn',
+ message: 'Top-level Legacy options are deprecated.',
+ hint: 'Move capture and port settings under "legacy", and browser behavior under "devtools".'
+ })
- test('调用 unregister 后恢复 https.request', () => {
- const originalRequest = https.request
- const unregister = register()
+ await handle.dispose()
- expect(https.request).not.toBe(originalRequest)
+ expect(mocks.unsetFetch).toHaveBeenCalledOnce()
+ expect(mocks.unsetUndiciFetch).toHaveBeenCalledOnce()
+ expect(mocks.mainProcessDispose).toHaveBeenCalledOnce()
+ expect(http.request).toBe(originalHttpRequest)
+ expect(https.request).toBe(originalHttpsRequest)
+ })
- if (unregister) unregister()
+ test('legacy namespaced options also activate configured interceptors', async () => {
+ const handle = register({
+ mode: 'legacy',
+ legacy: {
+ port: 7070,
+ serverPort: 7071,
+ intercept: {
+ fetch: false,
+ normal: false,
+ undici: { fetch: true }
+ }
+ }
+ })
- expect(https.request).toBe(originalRequest)
- })
+ await handle.ready
- test('调用 unregister 后调用 MainProcess.dispose', () => {
- mockDispose.mockClear()
+ expect(mocks.mainProcessConstructorCalls[0]).toMatchObject({
+ port: 7070,
+ serverPort: 7071,
+ autoOpenDevtool: false
+ })
+ expect(mocks.proxyFetch).not.toHaveBeenCalled()
+ expect(mocks.requestProxyFactory).not.toHaveBeenCalled()
+ expect(mocks.undiciFetchProxy).toHaveBeenCalledOnce()
+ expect(http.request).toBe(originalHttpRequest)
+ expect(https.request).toBe(originalHttpsRequest)
+ })
- const unregister = register()
+ test('the same normalized configuration returns one idempotent handle', async () => {
+ const first = register({
+ mode: 'legacy',
+ requiredCapabilities: ['responseBody', 'fetch'],
+ legacy: { port: 6000, serverPort: 6001 }
+ })
+ const second = register({
+ mode: 'legacy',
+ requiredCapabilities: ['fetch', 'responseBody'],
+ legacy: { port: 6000, serverPort: 6001 }
+ })
- if (unregister) unregister()
+ expect(second).toBe(first)
+ await first.ready
+ expect(mocks.mainProcessConstructorCalls).toHaveLength(1)
+ expect(mocks.proxyFetch).toHaveBeenCalledOnce()
+ })
- expect(mockDispose).toHaveBeenCalled()
- })
+ test('a conflicting active configuration fails synchronously with a stable error', async () => {
+ const active = register({ mode: 'legacy', legacy: { port: 6000 } })
- test('禁用 fetch 拦截时 unregister 不调用 fetch 清理函数', () => {
- const unregister = register({
- intercept: {
- fetch: false
- }
- })
-
- // 不应该抛出错误
- expect(() => {
- if (unregister) unregister()
- }).not.toThrow()
+ expect(() => register({ mode: 'legacy', legacy: { port: 6001 } })).toThrowError(
+ expect.objectContaining({
+ name: 'RuntimeRegistrationError',
+ code: 'NND_ALREADY_REGISTERED',
+ message: 'Node Network Devtools is already registered with a different configuration.'
})
+ )
+ expect(() => register({ mode: 'native' })).toThrow(RuntimeRegistrationError)
- test('禁用 normal 拦截时 unregister 不恢复 http/https', () => {
- const originalHttpRequest = http.request
- const originalHttpsRequest = https.request
-
- const unregister = register({
- intercept: {
- normal: false
- }
- })
-
- // http/https.request 应该保持不变
- expect(http.request).toBe(originalHttpRequest)
- expect(https.request).toBe(originalHttpsRequest)
-
- if (unregister) unregister()
- })
+ await active.ready
+ expect(mocks.mainProcessConstructorCalls).toHaveLength(1)
+ })
- test('启用 undici 拦截时 unregister 调用 undici 清理函数', () => {
- const mockUnsetUndici = vi.fn()
- vi.mocked(undiciFetchProxy).mockReturnValue(mockUnsetUndici)
+ test('callable cleanup and async dispose share one idempotent cleanup', async () => {
+ const handle = register({ mode: 'legacy' })
+ await handle.ready
- const unregister = register({
- intercept: {
- undici: {
- fetch: true
- }
- }
- })
+ expect(handle()).toBeUndefined()
+ await Promise.all([handle.dispose(), handle.dispose()])
- if (unregister) unregister()
+ expect(mocks.unsetFetch).toHaveBeenCalledOnce()
+ expect(mocks.mainProcessDispose).toHaveBeenCalledOnce()
+ expect(handle.status()).toEqual({ state: 'disposed', mode: 'legacy' })
- expect(mockUnsetUndici).toHaveBeenCalled()
- })
- })
+ await handle.dispose()
+ expect(mocks.mainProcessDispose).toHaveBeenCalledOnce()
+ })
- describe('多次注册', () => {
- test('多次注册创建多个 MainProcess 实例', () => {
- const unregister1 = register()
- const unregister2 = register()
+ test('browser opening defaults to false and remains explicitly callable', async () => {
+ const handle = register({ mode: 'legacy' })
+ const ready = await handle.ready
- expect(mainProcessConstructorCalls).toHaveLength(2)
+ expect(mocks.mainProcessConstructorCalls[0]).toMatchObject({ autoOpenDevtool: false })
+ expect(mocks.openDevtoolsTarget).not.toHaveBeenCalled()
- if (unregister1) unregister1()
- if (unregister2) unregister2()
- })
- })
+ await handle.openDevtools()
+ expect(mocks.openDevtoolsTarget).toHaveBeenCalledOnce()
+ expect(mocks.openDevtoolsTarget).toHaveBeenCalledWith(ready.target)
})
})
diff --git a/packages/network-debugger/src/core/index.ts b/packages/network-debugger/src/core/index.ts
index a59ce48..1f51ef3 100644
--- a/packages/network-debugger/src/core/index.ts
+++ b/packages/network-debugger/src/core/index.ts
@@ -1,69 +1,21 @@
-import http from 'http'
-import https from 'https'
-import { requestProxyFactory } from './request'
-import { MainProcess } from './fork'
-import { proxyFetch } from './fetch'
-import { PORT, SERVER_PORT } from '../common'
-
-import { RegisterOptions } from '../common'
-import { generateHash } from '../utils'
-import { undiciFetchProxy } from './undici'
-
-export function register(props?: RegisterOptions) {
- const {
- port = PORT,
- serverPort = SERVER_PORT,
- autoOpenDevtool = true,
- intercept = {}
- } = props || {}
-
- const {
- fetch: isInterceptFetch = true,
- normal: isInterceptNormal = true,
- undici: isInterceptUndici = false
- } = intercept
-
- const interceptUndiciFetch = isInterceptUndici && isInterceptUndici.fetch
-
- const key = generateHash(JSON.stringify({ port, serverPort, autoOpenDevtool }))
- const mainProcess = new MainProcess({
- port,
- serverPort,
- autoOpenDevtool,
- key
- })
-
- // global fetch
- const unsetFetchProxy = isInterceptFetch ? proxyFetch(mainProcess) : void 0
-
- // http/https
- const originAgentRequests = new WeakMap()
- const agents = [http, https]
- if (isInterceptNormal) {
- agents.forEach((agent) => {
- originAgentRequests.set(agent, agent.request)
- const actualRequestHandlerFn = agent.request
- agent.request = requestProxyFactory(actualRequestHandlerFn, agent === https, mainProcess)
- })
- }
-
- // undici
- // undici fetch
- const unsetUndiciFetch = interceptUndiciFetch ? undiciFetchProxy(mainProcess) : void 0
-
- return () => {
- unsetFetchProxy && unsetFetchProxy()
- if (isInterceptNormal) {
- agents.forEach((agent) => {
- agent.request = originAgentRequests.get(agent)
- originAgentRequests.delete(agent)
- })
- }
-
- unsetUndiciFetch && unsetUndiciFetch()
-
- mainProcess.dispose()
- }
-}
-
+export { register } from '../runtime/controller'
+export type {
+ ReadyInfo,
+ RegistrationEvent,
+ RegistrationHandle,
+ RegistrationState,
+ RegistrationStatus
+} from '../runtime/registration'
+export type {
+ AdapterKind,
+ AdapterMode,
+ CapabilityMap,
+ DevtoolsTarget,
+ Diagnostic,
+ NetworkCapability
+} from '../adapters/types'
+export type { InterceptOptions, RegisterOptions } from '../common'
+export * from '../mock'
+export * from '../replay'
+export * from '../session'
export * from './hooks'
diff --git a/packages/network-debugger/src/core/request.test.ts b/packages/network-debugger/src/core/request.test.ts
index 2644339..4895e89 100644
--- a/packages/network-debugger/src/core/request.test.ts
+++ b/packages/network-debugger/src/core/request.test.ts
@@ -1,1009 +1,526 @@
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
-import { EventEmitter } from 'events'
-import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'
-import type { Socket } from 'net'
-import { RequestDetail } from '../common'
-import { requestProxyFactory } from './request'
+import { EventEmitter } from 'node:events'
+import type { ClientRequest, IncomingMessage, RequestOptions } from 'node:http'
+import type { Socket } from 'node:net'
+import { deserialize, serialize } from 'node:v8'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { MainProcess } from './fork'
+import { getProxyFactory, requestProxyFactory, type RequestFn } from './request'
+import { withoutLegacyCapture } from './capture-scope'
+import PerMessageDeflate from './ws/permessage-deflate'
+
+interface JournalEntry {
+ transport: 'request' | 'event' | 'response'
+ type: string
+ data: any
+}
-// Mock MainProcess 接口
-interface MockMainProcess {
- sendRequest: ReturnType
- send: ReturnType
- responseRequest: ReturnType
+interface MockClientRequest extends ClientRequest {
+ protocol: string
+ host: string
+ destroyed: boolean
}
-// 创建 mock MainProcess
-function createMockMainProcess(): {
- mockMainProcess: MockMainProcess
- mockSendRequest: ReturnType
- mockSend: ReturnType
- mockResponseRequest: ReturnType
-} {
- const mockSendRequest = vi.fn().mockReturnThis()
- const mockSend = vi.fn().mockResolvedValue(undefined)
- const mockResponseRequest = vi.fn()
+function snapshot(value: T): T {
+ return deserialize(serialize(value)) as T
+}
- return {
- mockMainProcess: {
- sendRequest: mockSendRequest,
- send: mockSend,
- responseRequest: mockResponseRequest
- },
- mockSendRequest,
- mockSend,
- mockResponseRequest
- }
+function createMainProcess() {
+ const journal: JournalEntry[] = []
+ const mainProcess: Record = {}
+ mainProcess.sendRequest = vi.fn((type: string, data: unknown) => {
+ journal.push({ transport: 'request', type, data: snapshot(data) })
+ return mainProcess
+ })
+ mainProcess.send = vi.fn(async (event: { type: string; data: unknown }) => {
+ journal.push({ transport: 'event', type: event.type, data: snapshot(event.data) })
+ })
+ mainProcess.responseRequest = vi.fn((request: unknown, response: IncomingMessage) => {
+ journal.push({
+ transport: 'response',
+ type: 'responseRequest',
+ data: { request: snapshot(request), response }
+ })
+ })
+ return { journal, mainProcess: mainProcess as MainProcess }
}
-// 创建 Mock ClientRequest - 返回具有必要属性的对象
-function createMockClientRequest() {
- const emitter = new EventEmitter()
- const writeFn = vi.fn().mockReturnValue(true)
- const setHeaderFn = vi.fn()
-
- // 将 EventEmitter 方法和 mock 方法合并
- const mockRequest = {
- ...emitter,
- on: emitter.on.bind(emitter),
- emit: emitter.emit.bind(emitter),
- write: writeFn,
- end: vi.fn(),
- setHeader: setHeaderFn,
- getHeader: vi.fn(),
- removeHeader: vi.fn(),
- abort: vi.fn(),
- destroyed: false
+function createClientRequest(
+ overrides: Partial<{
+ protocol: string
+ host: string
+ path: string
+ method: string
+ headers: Record
+ }> = {}
+): MockClientRequest {
+ const request = new EventEmitter() as MockClientRequest
+ const headers: Record = {
+ host: overrides.host ?? 'example.test',
+ ...(overrides.headers ?? {})
}
+ request.protocol = overrides.protocol ?? 'http:'
+ request.host = overrides.host ?? 'example.test'
+ request.path = overrides.path ?? '/'
+ request.method = overrides.method ?? 'GET'
+ request.destroyed = false
+ request.getHeader = vi.fn((name: string) => {
+ const key = Object.keys(headers).find(
+ (candidate) => candidate.toLowerCase() === name.toLowerCase()
+ )
+ return key ? headers[key] : undefined
+ })
+ request.getHeaders = vi.fn(() => ({ ...headers }))
+ request.setHeader = vi.fn((name: string, value: string | string[] | number) => {
+ const existing = Object.keys(headers).find(
+ (candidate) => candidate.toLowerCase() === name.toLowerCase()
+ )
+ if (existing) delete headers[existing]
+ headers[name] = value
+ return request
+ }) as ClientRequest['setHeader']
+ request.removeHeader = vi.fn((name: string) => {
+ const existing = Object.keys(headers).find(
+ (candidate) => candidate.toLowerCase() === name.toLowerCase()
+ )
+ if (existing) delete headers[existing]
+ })
+ request.write = vi.fn(() => true) as ClientRequest['write']
+ request.end = vi.fn(() => request) as ClientRequest['end']
+ request.abort = vi.fn()
+ return request
+}
- return mockRequest
+function createResponse(overrides: Partial = {}): IncomingMessage {
+ const response = new EventEmitter() as IncomingMessage
+ response.statusCode = overrides.statusCode ?? 200
+ response.statusMessage = overrides.statusMessage ?? 'OK'
+ response.headers = overrides.headers ?? { 'content-type': 'application/json' }
+ response.rawHeaders = overrides.rawHeaders ?? ['Content-Type', 'application/json']
+ response.httpVersion = overrides.httpVersion ?? '1.1'
+ return response
}
-// 创建 Mock IncomingMessage
-function createMockIncomingMessage(
- options: {
- statusCode?: number
- headers?: Record
- } = {}
-) {
- const { statusCode = 200, headers = {} } = options
- const emitter = new EventEmitter()
-
- const mockResponse = {
- ...emitter,
- on: emitter.on.bind(emitter),
- emit: emitter.emit.bind(emitter),
- statusCode,
- headers,
- httpVersion: '1.1',
- complete: true,
- rawHeaders: [] as string[],
- trailers: {},
- rawTrailers: [] as string[]
- }
+function createSocket(): Socket {
+ const socket = new EventEmitter() as Socket
+ socket.write = vi.fn(() => true) as Socket['write']
+ socket.end = vi.fn(() => socket) as Socket['end']
+ socket.destroy = vi.fn(() => socket) as Socket['destroy']
+ return socket
+}
- return mockResponse
+function createActualRequest(request: ClientRequest) {
+ let responseCallback: ((response: IncomingMessage) => void) | undefined
+ const actualRequest = vi.fn((...args: unknown[]) => {
+ const candidate = args.at(-1)
+ if (typeof candidate === 'function') {
+ responseCallback = candidate as (response: IncomingMessage) => void
+ }
+ return request
+ }) as unknown as RequestFn
+ return {
+ actualRequest,
+ respond(response: IncomingMessage) {
+ expect(responseCallback).toBeTypeOf('function')
+ responseCallback!(response)
+ }
+ }
}
-// 创建 Mock Socket
-function createMockSocket() {
- const emitter = new EventEmitter()
- const writeFn = vi.fn().mockReturnValue(true)
- const readFn = vi.fn().mockReturnValue(null)
-
- const mockSocket = {
- ...emitter,
- on: emitter.on.bind(emitter),
- emit: emitter.emit.bind(emitter),
- addListener: emitter.addListener.bind(emitter),
- write: writeFn,
- end: vi.fn(),
- destroy: vi.fn(),
- read: readFn,
- destroyed: false,
- readable: true,
- writable: true
+function websocketFrame(
+ payload: Buffer,
+ opcode: 1 | 2,
+ masked: boolean,
+ compressed = false
+): Buffer {
+ if (payload.length >= 126) throw new Error('test helper only supports short frames')
+ const first = 0x80 | (compressed ? 0x40 : 0) | opcode
+ if (!masked) return Buffer.concat([Buffer.from([first, payload.length]), payload])
+
+ const mask = Buffer.from([0x11, 0x22, 0x33, 0x44])
+ const encoded = Buffer.alloc(payload.length)
+ for (let index = 0; index < payload.length; index += 1) {
+ encoded[index] = payload[index] ^ mask[index % mask.length]
}
+ return Buffer.concat([Buffer.from([first, 0x80 | payload.length]), mask, encoded])
+}
- return mockSocket
+function compress(extension: PerMessageDeflate, payload: Buffer): Promise {
+ return new Promise((resolve, reject) => {
+ extension.compress(payload, true, (error, result) => {
+ if (error) reject(error)
+ else if (!result) reject(new Error('permessage-deflate returned no payload'))
+ else resolve(result)
+ })
+ })
}
-describe('core/request.ts', () => {
- beforeEach(() => {
- vi.clearAllMocks()
+describe('http request capture', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ test('bypasses internal debugger transports without emitting capture events', () => {
+ const request = createClientRequest({ path: '/devtools/page/internal' })
+ const { actualRequest } = createActualRequest(request)
+ const { journal, mainProcess } = createMainProcess()
+ const callback = vi.fn()
+ const proxy = requestProxyFactory.call(undefined, actualRequest, false, mainProcess)
+
+ const returned = withoutLegacyCapture(() =>
+ proxy('http://127.0.0.1:43100/devtools/page/internal', callback)
+ )
+
+ expect(returned).toBe(request)
+ expect(actualRequest).toHaveBeenCalledWith(
+ 'http://127.0.0.1:43100/devtools/page/internal',
+ callback
+ )
+ expect(journal).toEqual([])
})
- afterEach(() => {
- vi.restoreAllMocks()
+ test('uses the actual ClientRequest origin, path, method, headers, and seconds timestamp', () => {
+ const request = createClientRequest({
+ protocol: 'http:',
+ host: '127.0.0.1:43891',
+ path: '/actual?query=1',
+ method: 'POST',
+ headers: { 'x-runtime': 'yes' }
+ })
+ const { actualRequest } = createActualRequest(request)
+ const { journal, mainProcess } = createMainProcess()
+ const before = Date.now() / 1000
+
+ requestProxyFactory.call(
+ undefined,
+ actualRequest,
+ false,
+ mainProcess
+ )('http://placeholder.invalid/stale', { method: 'GET' })
+ const after = Date.now() / 1000
+
+ const detail = journal[0].data
+ expect(detail).toMatchObject({
+ url: 'http://127.0.0.1:43891/actual?query=1',
+ method: 'POST',
+ requestHeaders: { host: '127.0.0.1:43891', 'x-runtime': 'yes' }
+ })
+ expect(detail.requestStartTime).toBeGreaterThanOrEqual(before)
+ expect(detail.requestStartTime).toBeLessThanOrEqual(after)
+ expect(detail.requestStartTime).toBeLessThan(10_000_000_000)
+ expect(actualRequest).toHaveBeenCalledWith(
+ 'http://placeholder.invalid/stale',
+ { method: 'GET' },
+ expect.any(Function)
+ )
})
- describe('requestProxyFactory 函数', () => {
- describe('参数解析', () => {
- test('处理字符串 URL 参数 (url, options, callback)', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const callback = vi.fn()
- proxyFn('http://example.com/api', { method: 'GET' }, callback)
-
- // 验证 initRequest 被调用
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'http://example.com/api',
- method: 'GET'
- })
- )
- })
-
- test('处理 URL 对象参数', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const url = new URL('http://example.com/api')
- proxyFn(url, { method: 'POST' })
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'http://example.com/api',
- method: 'POST'
- })
- )
- })
-
- test('处理 RequestOptions 参数 (options, callback)', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- }
- proxyFn(options)
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'http://example.com/api',
- method: 'POST'
- })
- )
- })
-
- test('处理 HTTPS 请求', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- true, // isHttps = true
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'https://example.com/api'
- })
- )
- })
-
- test('使用 host 替代 hostname', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- host: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: 'http://example.com/api'
- })
- )
- })
+ test('registers lazily once at end with every written body chunk and late headers', () => {
+ const request = createClientRequest({ method: 'POST', path: '/upload' })
+ const originalWrite = request.write
+ const originalEnd = request.end
+ const { actualRequest } = createActualRequest(request)
+ const { journal, mainProcess } = createMainProcess()
+ const captured = requestProxyFactory.call(
+ undefined,
+ actualRequest,
+ false,
+ mainProcess
+ )({
+ hostname: 'example.test',
+ path: '/upload',
+ method: 'POST'
})
- describe('请求头处理', () => {
- test('记录请求头', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: 'Bearer token'
- }
- }
- proxyFn(options)
-
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- requestHeaders: {
- 'Content-Type': 'application/json',
- Authorization: 'Bearer token'
- }
- })
- )
- })
-
- test('setHeader 方法代理 - 单个值', () => {
- const mockRequest = createMockClientRequest()
- const originalSetHeader = mockRequest.setHeader
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- // 需要传递 headers 以初始化 requestHeaders
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST',
- headers: {}
- }
- const request = proxyFn(options)
-
- // 调用代理后的 setHeader
- request.setHeader('X-Custom-Header', 'custom-value')
-
- // 验证原始 setHeader 被调用
- expect(originalSetHeader).toHaveBeenCalledWith('X-Custom-Header', 'custom-value')
- })
-
- test('setHeader 方法代理 - 数组值', () => {
- const mockRequest = createMockClientRequest()
- const originalSetHeader = mockRequest.setHeader
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- // 需要传递 headers 以初始化 requestHeaders
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST',
- headers: {}
- }
- const request = proxyFn(options)
-
- // 调用代理后的 setHeader,传入数组
- request.setHeader('Set-Cookie', ['cookie1=value1', 'cookie2=value2'])
-
- // 验证原始 setHeader 被调用
- expect(originalSetHeader).toHaveBeenCalledWith('Set-Cookie', [
- 'cookie1=value1',
- 'cookie2=value2'
- ])
- })
+ captured.write('first-', 'utf8')
+ captured.write(Buffer.from('second-'))
+ captured.setHeader('X-Late', 'visible')
+ expect(journal.map(({ type }) => type)).toEqual(['initRequest'])
+
+ captured.end('third')
+ captured.end()
+
+ expect(journal.map(({ type }) => type)).toEqual(['initRequest', 'registerRequest'])
+ const registered = journal[1].data
+ expect(Buffer.from(registered.requestData).toString()).toBe('first-second-third')
+ expect(registered.requestHeaders).toMatchObject({ 'X-Late': 'visible' })
+ expect(originalWrite).toHaveBeenCalledTimes(2)
+ expect(originalEnd).toHaveBeenCalledTimes(2)
+ })
+
+ test('registers before delegating an early response and preserves response metadata', () => {
+ const request = createClientRequest({ path: '/resource' })
+ const harness = createActualRequest(request)
+ const { journal, mainProcess } = createMainProcess()
+ const callback = vi.fn()
+ requestProxyFactory.call(
+ undefined,
+ harness.actualRequest,
+ false,
+ mainProcess
+ )({ hostname: 'example.test', path: '/resource' }, callback)
+ const response = createResponse({
+ statusCode: 202,
+ statusMessage: 'Accepted',
+ headers: { 'content-type': 'text/plain', 'x-response': 'ready' }
})
- describe('请求体处理', () => {
- test('write 方法代理 - JSON 数据', () => {
- const mockRequest = createMockClientRequest()
- const originalWrite = mockRequest.write
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST'
- }
- const request = proxyFn(options)
-
- const jsonData = JSON.stringify({ key: 'value' })
- request.write(jsonData)
-
- // 验证原始 write 被调用
- expect(originalWrite).toHaveBeenCalledWith(jsonData)
- })
-
- test('write 方法代理 - 非 JSON 数据', () => {
- const mockRequest = createMockClientRequest()
- const originalWrite = mockRequest.write
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST'
- }
- const request = proxyFn(options)
-
- const rawData = 'plain text data'
- request.write(rawData)
-
- // 验证原始 write 被调用
- expect(originalWrite).toHaveBeenCalledWith(rawData)
- })
-
- test('write 方法代理 - Buffer 数据', () => {
- const mockRequest = createMockClientRequest()
- const originalWrite = mockRequest.write
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'POST'
- }
- const request = proxyFn(options)
-
- const bufferData = Buffer.from('buffer data')
- request.write(bufferData)
-
- // 验证原始 write 被调用
- expect(originalWrite).toHaveBeenCalledWith(bufferData)
- })
+ harness.respond(response)
+
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'responseRequest'
+ ])
+ expect(journal.at(-1)!.data.request).toMatchObject({
+ responseStatusCode: 202,
+ responseStatusText: 'Accepted',
+ responseHeaders: { 'content-type': 'text/plain', 'x-response': 'ready' }
})
+ expect(journal.at(-1)!.data.response).toBe(response)
+ expect(callback).toHaveBeenCalledWith(response)
+ })
- describe('普通 HTTP 请求处理', () => {
- test('非 WebSocket 请求发送 registerRequest', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- // 验证 registerRequest 被调用
- expect(mockSendRequest).toHaveBeenCalledWith('registerRequest', expect.any(RequestDetail))
- })
-
- test('响应回调正确处理', () => {
- const mockRequest = createMockClientRequest()
- const mockResponse = createMockIncomingMessage({
- statusCode: 200,
- headers: { 'content-type': 'application/json' }
- })
-
- let capturedCallback: ((res: IncomingMessage) => void) | undefined
- const mockActualRequestHandler = vi
- .fn()
- .mockImplementation(
- (_options: RequestOptions, callback: (res: IncomingMessage) => void) => {
- capturedCallback = callback
- return mockRequest
- }
- )
- const { mockMainProcess, mockResponseRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const userCallback = vi.fn()
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options, userCallback)
-
- // 模拟响应
- expect(capturedCallback).toBeDefined()
- capturedCallback!(mockResponse as IncomingMessage)
-
- // 验证用户回调被调用
- expect(userCallback).toHaveBeenCalledWith(mockResponse)
- // 验证 responseRequest 被调用
- expect(mockResponseRequest).toHaveBeenCalled()
- })
-
- test('没有用户回调时也能正常处理响应', () => {
- const mockRequest = createMockClientRequest()
- const mockResponse = createMockIncomingMessage({ statusCode: 200 })
-
- let capturedCallback: ((res: IncomingMessage) => void) | undefined
- const mockActualRequestHandler = vi
- .fn()
- .mockImplementation(
- (_options: RequestOptions, callback: (res: IncomingMessage) => void) => {
- capturedCallback = callback
- return mockRequest
- }
- )
- const { mockMainProcess, mockResponseRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- // 不传递回调
- proxyFn(options)
-
- // 模拟响应
- expect(capturedCallback).toBeDefined()
- capturedCallback!(mockResponse as IncomingMessage)
-
- // 验证 responseRequest 被调用
- expect(mockResponseRequest).toHaveBeenCalled()
- })
+ test('reports a pre-response error only as requestFailed and only once', () => {
+ const request = createClientRequest({ path: '/failure' })
+ const { actualRequest } = createActualRequest(request)
+ const { journal, mainProcess } = createMainProcess()
+ requestProxyFactory.call(
+ undefined,
+ actualRequest,
+ false,
+ mainProcess
+ )({
+ hostname: 'example.test',
+ path: '/failure'
})
- describe('错误处理', () => {
- test('请求错误时发送 endRequest', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- // 模拟错误事件
- mockRequest.emit('error', new Error('Connection refused'))
-
- // 验证 endRequest 被调用,且状态码为 0
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'endRequest',
- expect.objectContaining({
- responseStatusCode: 0
- })
- )
- })
-
- test('请求错误时记录结束时间', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const beforeTime = Date.now()
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- // 模拟错误事件
- mockRequest.emit('error', new Error('Connection refused'))
- const afterTime = Date.now()
-
- // 获取 endRequest 调用的参数
- const endRequestCall = mockSendRequest.mock.calls.find((call) => call[0] === 'endRequest')
- expect(endRequestCall).toBeDefined()
- const requestDetail = endRequestCall![1] as RequestDetail
- expect(requestDetail.requestEndTime).toBeGreaterThanOrEqual(beforeTime)
- expect(requestDetail.requestEndTime).toBeLessThanOrEqual(afterTime)
- })
+ request.emit('error', new Error('socket refused'))
+ request.emit('abort')
+
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'requestFailed'
+ ])
+ const failure = journal.at(-1)!.data
+ expect(failure).toMatchObject({ errorText: 'socket refused', canceled: false })
+ expect(failure.request.requestEndTime).toBeLessThan(10_000_000_000)
+ })
+
+ test('treats an early destroyed close as canceled but ignores close after response headers', () => {
+ const early = createClientRequest({ path: '/early-close' })
+ const { actualRequest: earlyActual } = createActualRequest(early)
+ const earlyMain = createMainProcess()
+ requestProxyFactory.call(
+ undefined,
+ earlyActual,
+ false,
+ earlyMain.mainProcess
+ )({
+ hostname: 'example.test',
+ path: '/early-close'
})
+ early.destroyed = true
+ early.emit('close')
+ expect(earlyMain.journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'registerRequest',
+ 'requestFailed'
+ ])
+ expect(earlyMain.journal.at(-1)!.data.canceled).toBe(true)
+
+ const completed = createClientRequest({ path: '/has-headers' })
+ const completedHarness = createActualRequest(completed)
+ const completedMain = createMainProcess()
+ requestProxyFactory.call(
+ undefined,
+ completedHarness.actualRequest,
+ false,
+ completedMain.mainProcess
+ )({ hostname: 'example.test', path: '/has-headers' })
+ completedHarness.respond(createResponse())
+ completed.destroyed = true
+ completed.emit('close')
+ expect(completedMain.journal.some(({ type }) => type === 'requestFailed')).toBe(false)
+ })
+
+ test('get delegates to request and ends it, triggering lazy registration', () => {
+ const request = createClientRequest({ path: '/get' })
+ const proxiedRequest = vi.fn(() => request) as unknown as RequestFn
+
+ const returned = getProxyFactory(proxiedRequest)('http://example.test/get')
+
+ expect(returned).toBe(request)
+ expect(proxiedRequest).toHaveBeenCalledWith('http://example.test/get')
+ expect(request.end).toHaveBeenCalledOnce()
+ })
+})
- describe('WebSocket 请求处理', () => {
- test('WebSocket 请求 URL 转换为 ws:// 协议', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/',
- method: 'GET',
- headers: {
- Upgrade: 'websocket',
- Connection: 'Upgrade'
- }
- }
- proxyFn(options)
-
- // 验证 URL 被转换为 ws://
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: expect.stringMatching(/^ws:\/\//)
- })
- )
- })
-
- test('HTTPS WebSocket 请求 URL 转换为 wss:// 协议', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- true, // isHttps = true
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/',
- method: 'GET',
- headers: {
- Upgrade: 'websocket',
- Connection: 'Upgrade'
- }
- }
- proxyFn(options)
-
- // 验证 URL 被转换为 wss://
- expect(mockSendRequest).toHaveBeenCalledWith(
- 'initRequest',
- expect.objectContaining({
- url: expect.stringMatching(/^wss:\/\//)
- })
- )
- })
-
- test('WebSocket 请求不发送 registerRequest', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/',
- method: 'GET',
- headers: {
- Upgrade: 'websocket'
- }
- }
- proxyFn(options)
-
- // 验证 registerRequest 没有被调用
- const registerRequestCalls = mockSendRequest.mock.calls.filter(
- (call) => call[0] === 'registerRequest'
- )
- expect(registerRequestCalls.length).toBe(0)
- })
-
- test('WebSocket upgrade 事件处理 - 发送 webSocketCreated', async () => {
- const mockRequest = createMockClientRequest()
- const mockResponse = createMockIncomingMessage({ statusCode: 101 })
- const mockSocket = createMockSocket()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSend } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/ws',
- method: 'GET',
- headers: {
- Upgrade: 'websocket'
- }
- }
- proxyFn(options)
-
- // 模拟 upgrade 事件
- mockRequest.emit('upgrade', mockResponse, mockSocket, Buffer.alloc(0))
-
- // 等待异步操作
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- // 验证 webSocketCreated 消息被发送
- expect(mockSend).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'Network.webSocketCreated'
- })
- )
- })
-
- test('WebSocket 隐藏请求不发送消息', async () => {
- const mockRequest = createMockClientRequest()
- const mockResponse = createMockIncomingMessage({ statusCode: 101 })
- const mockSocket = createMockSocket()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSend } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- // 使用 127.0.0.1 的隐藏 URL
- const options: RequestOptions = {
- hostname: '127.0.0.1',
- path: '/',
- method: 'GET',
- headers: {
- Upgrade: 'websocket'
- }
- }
- proxyFn(options)
-
- // 模拟 upgrade 事件
- mockRequest.emit('upgrade', mockResponse, mockSocket, Buffer.alloc(0))
-
- // 等待异步操作
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- // 验证 webSocketCreated 消息没有被发送
- const webSocketCreatedCalls = mockSend.mock.calls.filter(
- (call) => call[0]?.type === 'Network.webSocketCreated'
- )
- expect(webSocketCreatedCalls.length).toBe(0)
- })
+describe('WebSocket request capture', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ function createWebSocketHarness() {
+ const request = createClientRequest({
+ protocol: 'http:',
+ host: '127.0.0.1:43917',
+ path: '/socket?token=dynamic',
+ headers: {
+ Upgrade: 'websocket',
+ Connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'test-key'
+ }
})
+ const actual = createActualRequest(request)
+ const capture = createMainProcess()
+ const returned = requestProxyFactory.call(
+ undefined,
+ actual.actualRequest,
+ false,
+ capture.mainProcess
+ )({ hostname: 'stale.invalid', path: '/wrong', headers: { Upgrade: 'websocket' } })
+ return { request, returned, ...actual, ...capture }
+ }
+
+ test('uses the dynamic ws URL and never registers a normal HTTP request', () => {
+ const { returned, journal } = createWebSocketHarness()
+
+ returned.end()
+
+ expect(journal.map(({ type }) => type)).toEqual(['initRequest'])
+ expect(journal[0].data.url).toBe('ws://127.0.0.1:43917/socket?token=dynamic')
+ })
- describe('WebSocket 帧处理', () => {
- test('WebSocket socket close 事件发送 webSocketClosed', async () => {
- const mockRequest = createMockClientRequest()
- const mockResponse = createMockIncomingMessage({ statusCode: 101 })
- const mockSocket = createMockSocket()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSend } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/ws',
- method: 'GET',
- headers: {
- Upgrade: 'websocket'
- }
- }
- proxyFn(options)
-
- // 模拟 upgrade 事件
- mockRequest.emit('upgrade', mockResponse, mockSocket, Buffer.alloc(0))
-
- // 等待异步操作
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- // 模拟 socket close 事件
- mockSocket.emit('close')
-
- // 等待异步操作
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- // 验证 webSocketClosed 消息被发送
- expect(mockSend).toHaveBeenCalledWith(
- expect.objectContaining({
- method: 'Network.webSocketClosed'
- })
- )
- })
-
- test('WebSocket socket end 事件正确处理', async () => {
- const mockRequest = createMockClientRequest()
- const mockResponse = createMockIncomingMessage({ statusCode: 101 })
- const mockSocket = createMockSocket()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/ws',
- method: 'GET',
- headers: {
- Upgrade: 'websocket'
- }
- }
- proxyFn(options)
-
- // 模拟 upgrade 事件
- mockRequest.emit('upgrade', mockResponse, mockSocket, Buffer.alloc(0))
-
- // 等待异步操作
- await new Promise((resolve) => setTimeout(resolve, 10))
-
- // 模拟 socket end 事件 - 不应该抛出错误
- expect(() => mockSocket.emit('end')).not.toThrow()
- })
+ test('emits a serializable handshake DTO for loopback application traffic', () => {
+ const { request, journal } = createWebSocketHarness()
+ const socket = createSocket()
+ const response = createResponse({
+ statusCode: 101,
+ statusMessage: 'Switching Protocols',
+ headers: { upgrade: 'websocket', connection: 'Upgrade' },
+ rawHeaders: ['Upgrade', 'websocket', 'Connection', 'Upgrade']
})
- describe('原始请求处理器调用', () => {
- test('字符串 URL 参数正确传递给原始处理器', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const callback = vi.fn()
- proxyFn('http://example.com/api', { method: 'POST' }, callback)
-
- // 验证原始处理器被调用,参数正确
- expect(mockActualRequestHandler).toHaveBeenCalledWith(
- 'http://example.com/api',
- { method: 'POST' },
- expect.any(Function)
- )
- })
-
- test('RequestOptions 参数正确传递给原始处理器', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- // 验证原始处理器被调用,参数正确
- expect(mockActualRequestHandler).toHaveBeenCalledWith(options, expect.any(Function))
- })
-
- test('返回代理后的 ClientRequest', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- const result = proxyFn(options)
-
- // 验证返回的是代理后的请求对象
- expect(result).toBeDefined()
- expect(typeof result.write).toBe('function')
- expect(typeof result.setHeader).toBe('function')
- })
+ request.emit('upgrade', response, socket, Buffer.alloc(0))
+ request.destroyed = true
+ request.emit('close')
+
+ expect(journal.map(({ type }) => type)).toEqual(['initRequest', 'Network.webSocketCreated'])
+ const created = journal.at(-1)!.data
+ expect(created).toMatchObject({
+ requestId: journal[0].data.id,
+ url: 'ws://127.0.0.1:43917/socket?token=dynamic',
+ response: {
+ httpVersion: '1.1',
+ statusCode: 101,
+ statusMessage: 'Switching Protocols',
+ rawHeaders: ['Upgrade', 'websocket', 'Connection', 'Upgrade'],
+ headers: { upgrade: 'websocket', connection: 'Upgrade' }
+ }
})
+ expect(Object.keys(created).sort()).toEqual(['initiator', 'requestId', 'response', 'url'])
+ expect(() => JSON.stringify(created)).not.toThrow()
+ })
- describe('调用栈加载', () => {
- test('请求创建时加载调用栈', () => {
- const mockRequest = createMockClientRequest()
- const mockActualRequestHandler = vi.fn().mockReturnValue(mockRequest)
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- // 获取 initRequest 调用的参数
- const initRequestCall = mockSendRequest.mock.calls.find((call) => call[0] === 'initRequest')
- expect(initRequestCall).toBeDefined()
- const requestDetail = initRequestCall![1] as RequestDetail
-
- // 验证 RequestDetail 实例被创建
- expect(requestDetail).toBeInstanceOf(RequestDetail)
- })
+ test('emits text, binary, and one close DTO with the correct frame encoding', async () => {
+ const { request, journal } = createWebSocketHarness()
+ const socket = createSocket()
+ request.emit('upgrade', createResponse({ statusCode: 101 }), socket, Buffer.alloc(0))
+
+ socket.emit('data', websocketFrame(Buffer.from('server text'), 1, false))
+ socket.write(websocketFrame(Buffer.from([0, 255, 16]), 2, true))
+ socket.emit('end')
+ socket.emit('close')
+
+ await vi.waitFor(() => expect(journal).toHaveLength(5))
+
+ expect(journal.map(({ type }) => type)).toEqual([
+ 'initRequest',
+ 'Network.webSocketCreated',
+ 'Network.webSocketFrameReceived',
+ 'Network.webSocketFrameSent',
+ 'Network.webSocketClosed'
+ ])
+ expect(journal[2].data.response).toEqual({
+ payloadData: 'server text',
+ opcode: 1,
+ mask: false
+ })
+ expect(journal[3].data.response).toEqual({
+ payloadData: Buffer.from([0, 255, 16]).toString('base64'),
+ opcode: 2,
+ mask: true
})
+ expect(journal.filter(({ type }) => type === 'Network.webSocketClosed')).toHaveLength(1)
+ })
- describe('响应头处理', () => {
- test('响应头正确记录到 RequestDetail', () => {
- const mockRequest = createMockClientRequest()
- const responseHeaders = {
- 'content-type': 'application/json',
- 'x-custom-header': 'custom-value'
- }
- const mockResponse = createMockIncomingMessage({
- statusCode: 200,
- headers: responseHeaders
- })
-
- let capturedCallback: ((res: IncomingMessage) => void) | undefined
- const mockActualRequestHandler = vi
- .fn()
- .mockImplementation(
- (_options: RequestOptions, callback: (res: IncomingMessage) => void) => {
- capturedCallback = callback
- return mockRequest
- }
- )
- const { mockMainProcess, mockSendRequest } = createMockMainProcess()
-
- const proxyFn = requestProxyFactory.call(
- null,
- mockActualRequestHandler,
- false,
- mockMainProcess as never
- )
-
- const options: RequestOptions = {
- hostname: 'example.com',
- path: '/api',
- method: 'GET'
- }
- proxyFn(options)
-
- // 模拟响应
- expect(capturedCallback).toBeDefined()
- capturedCallback!(mockResponse as IncomingMessage)
-
- // 获取 initRequest 调用的参数,验证 responseHeaders 被设置
- const initRequestCall = mockSendRequest.mock.calls.find((call) => call[0] === 'initRequest')
- expect(initRequestCall).toBeDefined()
- const requestDetail = initRequestCall![1] as RequestDetail
- expect(requestDetail.responseHeaders).toEqual(responseHeaders)
- })
+ test('decodes negotiated permessage-deflate frames in both directions', async () => {
+ const { request, journal } = createWebSocketHarness()
+ const socket = createSocket()
+ const negotiated = {
+ server_no_context_takeover: [true],
+ client_no_context_takeover: [true]
+ }
+ const response = createResponse({
+ statusCode: 101,
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'sec-websocket-extensions':
+ 'permessage-deflate; server_no_context_takeover; client_no_context_takeover'
+ }
+ })
+ request.emit('upgrade', response, socket, Buffer.alloc(0))
+
+ const serverExtension = new PerMessageDeflate(
+ { serverNoContextTakeover: true, clientNoContextTakeover: true },
+ true
+ )
+ serverExtension.accept([structuredClone(negotiated)])
+ const clientExtension = new PerMessageDeflate(
+ { serverNoContextTakeover: true, clientNoContextTakeover: true },
+ false
+ )
+ clientExtension.accept([structuredClone(negotiated)])
+
+ const receivedPayload = Buffer.from('compressed server text')
+ const sentPayload = Buffer.from([0, 255, 16, 32, 64])
+ socket.emit(
+ 'data',
+ websocketFrame(await compress(serverExtension, receivedPayload), 1, false, true)
+ )
+ const sentFrame = websocketFrame(await compress(clientExtension, sentPayload), 2, true, true)
+ const applicationFrame = Buffer.from(sentFrame)
+ socket.write(sentFrame)
+ expect(sentFrame).toEqual(applicationFrame)
+
+ await vi.waitFor(() => {
+ expect(journal.filter(({ type }) => type.includes('webSocketFrame'))).toHaveLength(2)
+ })
+ expect(journal.at(-2)?.data.response).toEqual({
+ payloadData: receivedPayload.toString(),
+ opcode: 1,
+ mask: false
})
+ expect(journal.at(-1)?.data.response).toEqual({
+ payloadData: sentPayload.toString('base64'),
+ opcode: 2,
+ mask: true
+ })
+
+ socket.emit('close')
+ serverExtension.cleanup()
+ clientExtension.cleanup()
+ })
+
+ test('contains asynchronous Receiver errors instead of crashing application I/O', async () => {
+ const { request, journal } = createWebSocketHarness()
+ const socket = createSocket()
+ request.emit('upgrade', createResponse({ statusCode: 101 }), socket, Buffer.alloc(0))
+
+ // RSV1 without a negotiated extension is invalid and fails through the
+ // Writable callback, not through the synchronous write() call.
+ socket.emit('data', websocketFrame(Buffer.from('invalid'), 1, false, true))
+ await new Promise((resolve) => setImmediate(resolve))
+
+ expect(journal.map(({ type }) => type)).toEqual(['initRequest', 'Network.webSocketCreated'])
+ expect(() => socket.emit('close')).not.toThrow()
})
})
diff --git a/packages/network-debugger/src/core/request.ts b/packages/network-debugger/src/core/request.ts
index a07b611..89c1a2b 100644
--- a/packages/network-debugger/src/core/request.ts
+++ b/packages/network-debugger/src/core/request.ts
@@ -1,10 +1,13 @@
-import { ClientRequest, IncomingMessage, RequestOptions } from 'http'
-import { Socket } from 'node:net'
+import type { ClientRequest, IncomingMessage, RequestOptions } from 'node:http'
+import type { Socket } from 'node:net'
import { RequestDetail } from '../common'
-import { getTimestamp } from '../utils'
-import { MainProcess } from './fork'
+import type { LegacyWebSocketHandshake } from '../legacy-bridge/contracts'
+import type { MainProcess } from './fork'
import { BINARY_TYPES } from './ws/constants'
+import { parsePerMessageDeflate } from './ws/extension'
+import PerMessageDeflate from './ws/permessage-deflate'
import { Receiver } from './ws/receiver'
+import { isLegacyCaptureSuppressed } from './capture-scope'
export interface RequestFn {
(options: RequestOptions | string | URL, callback?: (res: IncomingMessage) => void): ClientRequest
@@ -15,247 +18,413 @@ export interface RequestFn {
): ClientRequest
}
-function proxyClientRequestFactory(
- actualRequest: ClientRequest,
- requestDetail: RequestDetail,
- mainProcess: MainProcess
-) {
- const actualFn = actualRequest.write
- actualRequest.write = (data: any) => {
- // Convert to string at source to avoid IPC serialization issues
- // Accumulate multiple writes (e.g., multipart/form-data)
- let chunk: string
- if (Buffer.isBuffer(data)) {
- chunk = data.toString('utf-8')
- } else if (typeof data === 'string') {
- chunk = data
- } else if (data != null) {
- chunk = JSON.stringify(data)
- } else {
- chunk = ''
- }
-
- if (requestDetail.requestData) {
- requestDetail.requestData += chunk
- } else {
- requestDetail.requestData = chunk
- }
+function errorText(error: unknown): string {
+ if (error instanceof Error) return error.message
+ return String(error || 'Request failed')
+}
- return actualFn.bind(actualRequest)(data)
+function responseHandshake(response: IncomingMessage): LegacyWebSocketHandshake {
+ return {
+ httpVersion: response.httpVersion,
+ statusCode: response.statusCode ?? 101,
+ statusMessage: response.statusMessage ?? 'Switching Protocols',
+ rawHeaders: [...response.rawHeaders],
+ headers: { ...response.headers }
}
+}
- actualRequest.on('error', () => {
- requestDetail.responseStatusCode = 0
- requestDetail.requestEndTime = new Date().getTime()
- mainProcess.sendRequest('endRequest', requestDetail)
- })
+function framePayload(data: unknown, isBinary: boolean): string {
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data as any)
+ return isBinary ? buffer.toString('base64') : buffer.toString('utf8')
+}
- if (requestDetail.isWebSocket()) {
- actualRequest.on('upgrade', async (res: IncomingMessage, socket: Socket, head: Buffer) => {
- const originalWrite = socket.write
+function cloneExtensionConfigurations(
+ configurations: readonly Record[]
+): Record[] {
+ return configurations.map((parameters) =>
+ Object.fromEntries(Object.entries(parameters).map(([name, values]) => [name, [...values]]))
+ )
+}
- if (requestDetail.isHiden()) {
- return
- }
+function negotiatedWebSocketExtensions(response: IncomingMessage): {
+ receiver: Record
+ sender: Record
+ cleanupReceiver(): void
+ cleanupSender(): void
+} {
+ let receiverExtension: PerMessageDeflate | undefined
+ let senderExtension: PerMessageDeflate | undefined
- await mainProcess.send({
- type: 'Network.webSocketCreated',
- data: {
- requestId: requestDetail.id,
- url: requestDetail.url,
- initiator: requestDetail.initiator,
- response: res
- }
- })
+ try {
+ const configurations = parsePerMessageDeflate(response.headers['sec-websocket-extensions'])
+ if (configurations) {
+ // Incoming bytes are server -> client, while intercepted socket writes
+ // are client -> server. Each direction needs its own zlib context.
+ receiverExtension = new PerMessageDeflate({}, false)
+ receiverExtension.accept(cloneExtensionConfigurations(configurations))
+ senderExtension = new PerMessageDeflate({}, true)
+ senderExtension.accept(cloneExtensionConfigurations(configurations))
+ }
+ } catch {
+ // The owning WebSocket implementation validates the handshake. Capture is
+ // observational and must never crash the application for a bad extension.
+ receiverExtension?.cleanup()
+ senderExtension?.cleanup()
+ receiverExtension = undefined
+ senderExtension = undefined
+ }
- const receiver = new Receiver({
- allowSynchronousEvents: true,
- binaryType: BINARY_TYPES[0],
- isServer: false
- })
- const sender = new Receiver({
- allowSynchronousEvents: true,
- binaryType: BINARY_TYPES[0],
- isServer: true
- })
+ return {
+ receiver: receiverExtension ? { [PerMessageDeflate.extensionName]: receiverExtension } : {},
+ sender: senderExtension ? { [PerMessageDeflate.extensionName]: senderExtension } : {},
+ cleanupReceiver: () => receiverExtension?.cleanup(),
+ cleanupSender: () => senderExtension?.cleanup()
+ }
+}
- const receiverHandler = (data: any) => {
- const str = data.toString()
- // const socketMessage = jsonParse(str, str)
- mainProcess.send({
- type: 'Network.webSocketFrameReceived',
- data: {
- requestId: requestDetail.id,
- response: {
- payloadData: str,
- opcode: 1,
- mask: false
- }
- }
- })
- }
+function writeCapture(receiver: Receiver, data: unknown): void {
+ if (receiver.writableEnded || receiver.destroyed) return
+ try {
+ // Receiver unmasks client frames in place. Always copy so observation can
+ // never mutate the bytes that the application is about to put on the wire.
+ receiver.write(Buffer.from(data as any))
+ } catch {
+ // A malformed frame or socket shutdown must not affect application I/O.
+ }
+}
- const senderHanlder = (data: any) => {
- const str = data.toString()
- mainProcess.send({
- type: 'Network.webSocketFrameSent',
- data: {
- requestId: requestDetail.id,
- response: {
- payloadData: str,
- opcode: 1,
- mask: true
- }
- }
- })
+function endCapture(receiver: Receiver): void {
+ if (receiver.writableEnded || receiver.destroyed) return
+ try {
+ receiver.end()
+ } catch {
+ // Capture teardown remains isolated from the application socket.
+ }
+}
+
+function installWebSocketCapture(
+ request: ClientRequest,
+ requestDetail: RequestDetail,
+ mainProcess: MainProcess
+): void {
+ request.on('upgrade', (response: IncomingMessage, socket: Socket, head: Buffer) => {
+ void mainProcess.send({
+ type: 'Network.webSocketCreated',
+ data: {
+ requestId: requestDetail.id,
+ url: requestDetail.url ?? '',
+ initiator: requestDetail.initiator,
+ response: responseHandshake(response)
}
+ })
- receiver.on('message', receiverHandler)
- sender.on('message', senderHanlder)
- let chunk
+ const extensions = negotiatedWebSocketExtensions(response)
+ const receiver = new Receiver({
+ allowSynchronousEvents: true,
+ binaryType: BINARY_TYPES[0],
+ extensions: extensions.receiver,
+ isServer: false
+ })
+ const sender = new Receiver({
+ allowSynchronousEvents: true,
+ binaryType: BINARY_TYPES[0],
+ extensions: extensions.sender,
+ isServer: true
+ })
- socket.write = (data: any, ...rest: any[]) => {
- const buf = Buffer.from(data)
- sender.write(buf)
- return originalWrite.call(socket, data, ...rest)
- }
- socket.addListener('data', (data) => {
- const buf = Buffer.from(data)
- receiver.write(buf)
+ // Writable reports parser failures asynchronously. Retaining these
+ // listeners is what keeps observational capture errors out of user code.
+ receiver.on('error', () => undefined)
+ sender.on('error', () => undefined)
+ let closeRequested = false
+ let closeSent = false
+ let receiverSettled = false
+ let senderSettled = false
+ const sendClosedWhenSettled = () => {
+ if (!closeRequested || closeSent || !receiverSettled || !senderSettled) return
+ closeSent = true
+ void mainProcess.send({
+ type: 'Network.webSocketClosed',
+ data: { requestId: requestDetail.id }
})
- socket.addListener('close', () => {
- chunk = socket.read()
- if (chunk !== null) {
- receiver.write(chunk)
- sender.write(chunk)
- }
- receiver.end()
- sender.end()
- receiver.removeAllListeners()
- sender.removeAllListeners()
- mainProcess.send({
- method: 'Network.webSocketClosed',
- params: {
- requestId: requestDetail.id,
- timestamp: getTimestamp()
+ }
+ const settleReceiver = () => {
+ if (receiverSettled) return
+ receiverSettled = true
+ extensions.cleanupReceiver()
+ sendClosedWhenSettled()
+ }
+ const settleSender = () => {
+ if (senderSettled) return
+ senderSettled = true
+ extensions.cleanupSender()
+ sendClosedWhenSettled()
+ }
+ receiver.once('finish', settleReceiver)
+ receiver.once('close', settleReceiver)
+ sender.once('finish', settleSender)
+ sender.once('close', settleSender)
+
+ receiver.on('message', (data: unknown, isBinary: boolean) => {
+ void mainProcess.send({
+ type: 'Network.webSocketFrameReceived',
+ data: {
+ requestId: requestDetail.id,
+ response: {
+ payloadData: framePayload(data, isBinary),
+ opcode: isBinary ? 2 : 1,
+ mask: false
}
- })
+ }
})
- socket.addListener('end', () => {
- receiver.end()
- sender.end()
- receiver.removeAllListeners()
- sender.removeAllListeners()
+ })
+ sender.on('message', (data: unknown, isBinary: boolean) => {
+ void mainProcess.send({
+ type: 'Network.webSocketFrameSent',
+ data: {
+ requestId: requestDetail.id,
+ response: {
+ payloadData: framePayload(data, isBinary),
+ opcode: isBinary ? 2 : 1,
+ mask: true
+ }
+ }
})
})
- } else {
- mainProcess.sendRequest('registerRequest', requestDetail)
- }
- return actualRequest
-}
+ const originalWrite = socket.write
+ socket.write = function (this: Socket, data: any, ...rest: any[]) {
+ writeCapture(sender, data)
+ return Reflect.apply(originalWrite, this, [data, ...rest])
+ } as typeof socket.write
-function proxyCallbackFactory(
- actualCallBack: any,
- requestDetail: RequestDetail,
- mainProcess: MainProcess
-) {
- return (response: IncomingMessage) => {
- requestDetail.responseHeaders = response.headers
- if (typeof actualCallBack === 'function') {
- actualCallBack(response)
+ if (head.length > 0) writeCapture(receiver, head)
+ socket.on('data', (data) => writeCapture(receiver, data))
+
+ const close = () => {
+ if (closeRequested) return
+ closeRequested = true
+ endCapture(receiver)
+ endCapture(sender)
+ sendClosedWhenSettled()
}
+ socket.on('close', close)
+ socket.on('end', close)
+ })
+}
+
+function initialUrl(
+ arg1: RequestOptions | string | URL,
+ options: RequestOptions | undefined,
+ isHttps: boolean
+): string {
+ if (typeof arg1 === 'string') return arg1
+ if (arg1 instanceof URL) return arg1.toString()
+ const protocol = options?.protocol ?? (isHttps ? 'https:' : 'http:')
+ const hostname = options?.hostname ?? options?.host ?? 'localhost'
+ const port = options?.port === undefined ? '' : `:${options.port}`
+ return `${protocol}//${hostname}${port}${options?.path ?? '/'}`
+}
- mainProcess.responseRequest(requestDetail.id, response)
+function requestOptions(
+ arg1: RequestOptions | string | URL,
+ arg2: RequestOptions | ((response: IncomingMessage) => void) | undefined
+): RequestOptions | undefined {
+ if (typeof arg1 === 'string' || arg1 instanceof URL) {
+ return typeof arg2 === 'object' ? arg2 : undefined
}
+ return arg1
}
-function proxySetHeader(request: ClientRequest, requestDetail: RequestDetail) {
- let originSetHeader = request.setHeader
- request.setHeader = function (name, val) {
- if (Array.isArray(val)) {
- // TODO: use string[] to send multiple headers with the same name.
- val.forEach((v) => {
- requestDetail.requestHeaders[name] = v
- })
- } else {
- requestDetail.requestHeaders[name] = val
- }
- return originSetHeader.call(request, name, val)
+function callbackFrom(
+ arg2: RequestOptions | ((response: IncomingMessage) => void) | undefined,
+ arg3: ((response: IncomingMessage) => void) | undefined
+): ((response: IncomingMessage) => void) | undefined {
+ return typeof arg2 === 'function' ? arg2 : arg3
+}
+
+function invokeRequest(
+ actualRequestHandler: RequestFn,
+ thisValue: unknown,
+ arg1: RequestOptions | string | URL,
+ arg2: RequestOptions | ((response: IncomingMessage) => void) | undefined,
+ callback: (response: IncomingMessage) => void
+): ClientRequest {
+ let args: unknown[]
+ if (typeof arg1 === 'string' || arg1 instanceof URL) {
+ args = typeof arg2 === 'object' ? [arg1, arg2, callback] : [arg1, callback]
+ } else {
+ args = [arg1, callback]
}
+ return Reflect.apply(actualRequestHandler, thisValue, args)
}
+/**
+ * Wrap `http.request`/`https.request` without changing their overload or stream
+ * semantics. Capture is registered immediately before `end()` (or before an
+ * early response/failure) so every `write()` chunk and `end(body)` mutation is
+ * visible in the CDP request.
+ */
export function requestProxyFactory(
- this: any,
- actualRequestHandler: any,
+ this: unknown,
+ actualRequestHandler: RequestFn,
isHttps: boolean,
mainProcess: MainProcess
-) {
- const fn: RequestFn = (arg1: any, arg2?: any, arg3?: any) => {
- // #region resolve arguments
- let url: string | URL | undefined
- let options: RequestOptions | string | URL | undefined
- let callback: ((res: IncomingMessage) => void) | undefined
-
- if (typeof arg1 === 'string' || arg1 instanceof URL) {
- // Signature: (url: string | URL, options: RequestOptions, callback?: (res: IncomingMessage) => void): ClientRequest;
- url = arg1
- options = arg2
- callback = arg3
- } else {
- // Signature: (options: RequestOptions | string | URL, callback?: (res: IncomingMessage) => void): ClientRequest;
- options = arg1
- callback = arg2
+): RequestFn {
+ const thisValue = this
+ return function requestProxy(
+ arg1: RequestOptions | string | URL,
+ arg2?: RequestOptions | ((response: IncomingMessage) => void),
+ arg3?: (response: IncomingMessage) => void
+ ): ClientRequest {
+ if (isLegacyCaptureSuppressed()) {
+ const args =
+ typeof arg1 === 'string' || arg1 instanceof URL
+ ? typeof arg2 === 'object'
+ ? [arg1, arg2, arg3]
+ : [arg1, arg2]
+ : [arg1, arg2]
+ return Reflect.apply(actualRequestHandler, thisValue, args)
}
+ const options = requestOptions(arg1, arg2)
+ const actualCallback = callbackFrom(arg2, arg3)
+ const detail = new RequestDetail()
+ detail.requestStartTime = Date.now() / 1000
+ detail.url = initialUrl(arg1, options, isHttps)
+ detail.method = options?.method ?? 'GET'
+ detail.requestHeaders = { ...(options?.headers ?? {}) }
+ detail.responseHeaders = {}
+ detail.loadCallFrames()
- const requestDetail = new RequestDetail()
-
- if (typeof url === 'string') {
- requestDetail.url = url
- requestDetail.method = 'GET'
- } else if (url instanceof URL) {
- requestDetail.url = url.toString()
- requestDetail.method = 'GET'
- } else if (options && typeof options !== 'string' && !(options instanceof URL)) {
- const connectionType = isHttps ? 'https' : 'http'
- requestDetail.url = `${connectionType}://${options.hostname || options.host}${options.path}`
+ let responseStarted = false
+ let terminal = false
+ let request!: ClientRequest
+
+ const fail = (error: unknown, canceled = false) => {
+ if (terminal) return
+ terminal = true
+ register()
+ detail.requestEndTime = Date.now() / 1000
+ void mainProcess.send({
+ type: 'requestFailed',
+ data: { request: detail, errorText: errorText(error), canceled }
+ })
}
- if (options && typeof options !== 'string' && !(options instanceof URL)) {
- requestDetail.method = options.method
- requestDetail.requestHeaders = options.headers
+ const proxyCallback = (response: IncomingMessage) => {
+ responseStarted = true
+ register()
+ detail.responseHeaders = { ...response.headers }
+ detail.responseStatusCode = response.statusCode ?? 0
+ detail.responseStatusText = response.statusMessage ?? ''
+ mainProcess.responseRequest(detail, response)
+ actualCallback?.(response)
}
- // #endregion
+ request = invokeRequest(actualRequestHandler, thisValue, arg1, arg2, proxyCallback)
- requestDetail.loadCallFrames()
- if (requestDetail.isWebSocket()) {
- requestDetail.url = requestDetail
- .url!.replace('http://', 'ws://')
- .replace('https://', 'wss://')
- mainProcess.sendRequest('initRequest', requestDetail)
- } else {
- mainProcess.sendRequest('initRequest', requestDetail)
+ const protocol = (request as ClientRequest & { protocol?: string }).protocol
+ const hostHeader = request.getHeader('host')
+ const host =
+ typeof hostHeader === 'string' || typeof hostHeader === 'number'
+ ? String(hostHeader)
+ : (request as ClientRequest & { host?: string }).host
+ const path = request.path
+ if (protocol && host && path) detail.url = `${protocol}//${host}${path}`
+ detail.method = request.method || detail.method || 'GET'
+ if (typeof request.getHeaders === 'function') detail.requestHeaders = request.getHeaders()
+ if (detail.isWebSocket()) {
+ detail.url = detail.url?.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
}
- const proxyCallback = proxyCallbackFactory(callback, requestDetail, mainProcess)
+ mainProcess.sendRequest('initRequest', detail)
- if (typeof arg1 === 'string' || arg1 instanceof URL) {
- // Call actualRequestHandler with 3 parameters
- const request: ClientRequest = actualRequestHandler(
- url!,
- options as RequestOptions,
- proxyCallback
+ const bodyChunks: Buffer[] = []
+ let registered = false
+ const appendBody = (chunk: unknown, encoding?: BufferEncoding) => {
+ if (chunk === undefined || chunk === null) return
+ const value = Buffer.isBuffer(chunk)
+ ? chunk
+ : chunk instanceof Uint8Array
+ ? Buffer.from(chunk)
+ : Buffer.from(String(chunk), encoding)
+ bodyChunks.push(value)
+ }
+ const register = () => {
+ if (registered || detail.isWebSocket()) return
+ registered = true
+ if (typeof request.getHeaders === 'function') detail.requestHeaders = request.getHeaders()
+ if (bodyChunks.length > 0) detail.requestData = Buffer.concat(bodyChunks)
+ mainProcess.sendRequest('registerRequest', detail)
+ }
+
+ const originalWrite = request.write
+ request.write = function (
+ this: ClientRequest,
+ chunk: any,
+ encodingOrCallback?: any,
+ callback?: any
+ ) {
+ appendBody(
+ chunk,
+ typeof encodingOrCallback === 'string' ? (encodingOrCallback as BufferEncoding) : undefined
)
- proxySetHeader(request, requestDetail)
- return proxyClientRequestFactory(request, requestDetail, mainProcess)
- } else {
- // Call actualRequestHandler with 2 parameters
- const request: ClientRequest = actualRequestHandler(options as RequestOptions, proxyCallback)
- proxySetHeader(request, requestDetail)
- return proxyClientRequestFactory(request, requestDetail, mainProcess)
+ return Reflect.apply(originalWrite, this, arguments)
+ } as typeof request.write
+
+ const originalEnd = request.end
+ request.end = function (
+ this: ClientRequest,
+ chunk?: any,
+ encodingOrCallback?: any,
+ callback?: any
+ ) {
+ if (typeof chunk !== 'function') {
+ appendBody(
+ chunk,
+ typeof encodingOrCallback === 'string'
+ ? (encodingOrCallback as BufferEncoding)
+ : undefined
+ )
+ }
+ register()
+ return Reflect.apply(originalEnd, this, arguments)
+ } as typeof request.end
+
+ request.on('error', (error) => fail(error))
+ request.on('abort', () => fail(new Error('Request aborted'), true))
+ request.on('response', () => {
+ responseStarted = true
+ })
+
+ if (detail.isWebSocket()) {
+ request.on('upgrade', () => {
+ // A successful 101 is terminal for ClientRequest; the upgraded socket
+ // owns the rest of the lifecycle. Its later request `close` is not an
+ // HTTP loading failure.
+ responseStarted = true
+ terminal = true
+ })
+ installWebSocketCapture(request, detail, mainProcess)
}
- }
- return fn
+ // A request can close without an error before receiving headers (for
+ // example, a locally destroyed request). Treat only that state as failure.
+ request.on('close', () => {
+ if (!responseStarted && !terminal && request.destroyed) {
+ fail(new Error('Request closed before a response was received'), true)
+ }
+ })
+
+ return request
+ } as RequestFn
+}
+
+/** Node implements `get()` as `request()` followed by `end()`. */
+export function getProxyFactory(request: RequestFn): RequestFn {
+ return function getProxy(this: unknown, ...args: any[]) {
+ const clientRequest = Reflect.apply(request, this, args)
+ clientRequest.end()
+ return clientRequest
+ } as RequestFn
}
diff --git a/packages/network-debugger/src/core/undici.ts b/packages/network-debugger/src/core/undici.ts
index 7f82e1a..8eff4c2 100644
--- a/packages/network-debugger/src/core/undici.ts
+++ b/packages/network-debugger/src/core/undici.ts
@@ -1,20 +1,26 @@
import { fetchProxyFactory } from './fetch'
import undici from 'undici'
import { MainProcess } from './fork'
+import type { LegacyMockRule } from '../mock'
-export const undiciFetchProxy = (mainProcess: MainProcess) => {
+export const undiciFetchProxy = (
+ mainProcess: MainProcess,
+ mockRules: readonly LegacyMockRule[] = []
+) => {
if (!undici.fetch) {
return
}
const originalFetch = undici.fetch
- undici['fetch'] = fetchProxyFactory(
- originalFetch as typeof globalThis.fetch,
- mainProcess
+ const proxy = (
+ mockRules.length > 0
+ ? fetchProxyFactory(originalFetch as typeof globalThis.fetch, mainProcess, mockRules)
+ : fetchProxyFactory(originalFetch as typeof globalThis.fetch, mainProcess)
) as typeof undici.fetch
+ undici['fetch'] = proxy
return () => {
- undici['fetch'] = originalFetch
+ if (undici.fetch === proxy) undici['fetch'] = originalFetch
}
}
diff --git a/packages/network-debugger/src/core/ws/extension.test.ts b/packages/network-debugger/src/core/ws/extension.test.ts
new file mode 100644
index 0000000..3ed262a
--- /dev/null
+++ b/packages/network-debugger/src/core/ws/extension.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from 'vitest'
+import { parsePerMessageDeflate } from './extension'
+
+describe('Sec-WebSocket-Extensions parsing', () => {
+ test('extracts negotiated permessage-deflate parameters and quoted values', () => {
+ expect(
+ parsePerMessageDeflate(
+ 'x-ignored; value=1, permessage-deflate; server_no_context_takeover; client_max_window_bits="12"'
+ )
+ ).toEqual([
+ {
+ server_no_context_takeover: [true],
+ client_max_window_bits: ['12']
+ }
+ ])
+ })
+
+ test('retains duplicate parameters so negotiation validation can reject them', () => {
+ expect(
+ parsePerMessageDeflate(
+ 'permessage-deflate; server_max_window_bits=12; server_max_window_bits=13'
+ )
+ ).toEqual([{ server_max_window_bits: ['12', '13'] }])
+ })
+
+ test('rejects malformed quoted values without evaluating them', () => {
+ expect(() => parsePerMessageDeflate('permessage-deflate; server_max_window_bits="12')).toThrow(
+ SyntaxError
+ )
+ })
+})
diff --git a/packages/network-debugger/src/core/ws/extension.ts b/packages/network-debugger/src/core/ws/extension.ts
new file mode 100644
index 0000000..67951fc
--- /dev/null
+++ b/packages/network-debugger/src/core/ws/extension.ts
@@ -0,0 +1,98 @@
+type ExtensionParameter = string | true
+type ExtensionParameters = Record
+
+const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
+
+function splitOutsideQuotes(value: string, delimiter: ',' | ';'): string[] {
+ const parts: string[] = []
+ let start = 0
+ let quoted = false
+ let escaped = false
+
+ for (let index = 0; index < value.length; index += 1) {
+ const character = value[index]
+ if (escaped) {
+ escaped = false
+ continue
+ }
+ if (quoted && character === '\\') {
+ escaped = true
+ continue
+ }
+ if (character === '"') {
+ quoted = !quoted
+ continue
+ }
+ if (!quoted && character === delimiter) {
+ parts.push(value.slice(start, index))
+ start = index + 1
+ }
+ }
+
+ if (quoted || escaped) throw new SyntaxError('Invalid Sec-WebSocket-Extensions header')
+ parts.push(value.slice(start))
+ return parts
+}
+
+function parseToken(value: string): string {
+ const token = value.trim()
+ if (!TOKEN.test(token)) throw new SyntaxError('Invalid Sec-WebSocket-Extensions token')
+ return token
+}
+
+function parseValue(value: string): string {
+ const candidate = value.trim()
+ if (!candidate.startsWith('"')) return parseToken(candidate)
+ if (!candidate.endsWith('"') || candidate.length < 3) {
+ throw new SyntaxError('Invalid Sec-WebSocket-Extensions quoted value')
+ }
+
+ let result = ''
+ let escaped = false
+ for (const character of candidate.slice(1, -1)) {
+ if (escaped) {
+ result += character
+ escaped = false
+ } else if (character === '\\') {
+ escaped = true
+ } else {
+ result += character
+ }
+ }
+ if (escaped || !TOKEN.test(result)) {
+ throw new SyntaxError('Invalid Sec-WebSocket-Extensions quoted value')
+ }
+ return result
+}
+
+/**
+ * Parse the negotiated permessage-deflate response parameters. The HTTP
+ * upgrade is observed below the WebSocket implementation, so capture needs an
+ * independent, transport-only view of the negotiated extension.
+ */
+export function parsePerMessageDeflate(
+ header: string | readonly string[] | undefined
+): ExtensionParameters[] | undefined {
+ if (header === undefined) return undefined
+ const configurations: ExtensionParameters[] = []
+
+ for (const extension of splitOutsideQuotes(
+ typeof header === 'string' ? header : header.join(','),
+ ','
+ )) {
+ const fields = splitOutsideQuotes(extension, ';')
+ const name = parseToken(fields.shift() ?? '').toLowerCase()
+ if (name !== 'permessage-deflate') continue
+
+ const parameters: ExtensionParameters = Object.create(null)
+ for (const field of fields) {
+ const equals = field.indexOf('=')
+ const parameterName = parseToken(equals === -1 ? field : field.slice(0, equals)).toLowerCase()
+ const parameterValue = equals === -1 ? true : parseValue(field.slice(equals + 1))
+ ;(parameters[parameterName] ??= []).push(parameterValue)
+ }
+ configurations.push(parameters)
+ }
+
+ return configurations.length > 0 ? configurations : undefined
+}
diff --git a/packages/network-debugger/src/core/ws/permessage-deflate.test.ts b/packages/network-debugger/src/core/ws/permessage-deflate.test.ts
index e84e6ac..be9578d 100644
--- a/packages/network-debugger/src/core/ws/permessage-deflate.test.ts
+++ b/packages/network-debugger/src/core/ws/permessage-deflate.test.ts
@@ -319,6 +319,38 @@ describe('PerMessageDeflate', () => {
clientPmd.cleanup()
})
+ it('应该在连续消息之间清空缓冲并维护压缩上下文', async () => {
+ const serverPmd = new PerMessageDeflate({}, true)
+ serverPmd.accept([{}])
+ const clientPmd = new PerMessageDeflate({}, false)
+ clientPmd.accept([{}])
+
+ const compress = (data: string) =>
+ new Promise((resolve, reject) => {
+ serverPmd.compress(data, true, (error, result) => {
+ if (error) reject(error)
+ else if (!result) reject(new Error('Compression returned no data'))
+ else resolve(result)
+ })
+ })
+ const decompress = (data: Buffer) =>
+ new Promise((resolve, reject) => {
+ clientPmd.decompress(data, true, (error, result) => {
+ if (error) reject(error)
+ else if (!result) reject(new Error('Decompression returned no data'))
+ else resolve(result)
+ })
+ })
+
+ const first = await decompress(await compress('first compressed message'))
+ const second = await decompress(await compress('second compressed message'))
+
+ expect(first.toString()).toBe('first compressed message')
+ expect(second.toString()).toBe('second compressed message')
+ serverPmd.cleanup()
+ clientPmd.cleanup()
+ })
+
it('应该在超过 maxPayload 时返回错误(解压缩)', async () => {
const serverPmd = new PerMessageDeflate({}, true)
serverPmd.accept([{}])
diff --git a/packages/network-debugger/src/core/ws/permessage-deflate.ts b/packages/network-debugger/src/core/ws/permessage-deflate.ts
index fe870ea..e67595b 100644
--- a/packages/network-debugger/src/core/ws/permessage-deflate.ts
+++ b/packages/network-debugger/src/core/ws/permessage-deflate.ts
@@ -272,32 +272,43 @@ export class PerMessageDeflate {
this._inflate.on('data', inflateOnData)
}
- ;(this._inflate as any)[kCallback] = callback
+ const inflate = this._inflate
+ const state = inflate as any
+ state[kCallback] = callback
- this._inflate.write(data)
- if (fin) this._inflate.write(TRAILER)
+ inflate.write(data)
+ if (fin) inflate.write(TRAILER)
- this._inflate.flush(() => {
- const err = (this._inflate as any)[kError]
+ inflate.flush(() => {
+ // An inflate error calls the write callback and clears `_inflate`.
+ if (this._inflate !== inflate) return
+ const err = state[kError]
if (err) {
- this._inflate!.close()
+ inflate.close()
this._inflate = null
+ state[kCallback] = undefined
callback(err)
return
}
- const data = bufferUtil.concat(
- (this._inflate as any)[kBuffers],
- (this._inflate as any)[kTotalLength]
- )
+ const result = bufferUtil.concat(state[kBuffers], state[kTotalLength])
+ state[kCallback] = undefined
- if (this._maxPayload < 1 || data.length <= this._maxPayload) {
- callback(null, data)
- return
+ if (state._readableState?.endEmitted) {
+ inflate.close()
+ this._inflate = null
+ } else {
+ state[kTotalLength] = 0
+ state[kBuffers] = []
+ if (fin && this.params?.[`${endpoint}_no_context_takeover`]) inflate.reset()
}
- callback(new RangeError('Max payload size exceeded'), null)
+ if (this._maxPayload > 0 && result.length > this._maxPayload) {
+ callback(new RangeError('Max payload size exceeded'), null)
+ } else {
+ callback(null, result)
+ }
})
}
@@ -319,48 +330,66 @@ export class PerMessageDeflate {
...this._options.zlibDeflateOptions,
windowBits
})
+ ;(this._deflate as any)[kPerMessageDeflate] = this
;(this._deflate as any)[kTotalLength] = 0
;(this._deflate as any)[kBuffers] = []
this._deflate.on('error', deflateOnError)
this._deflate.on('data', deflateOnData)
}
- ;(this._deflate as any)[kCallback] = callback
+ const deflate = this._deflate
+ const state = deflate as any
+ state[kCallback] = callback
- this._deflate.write(data)
- if (fin)
- this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
- const data = bufferUtil.concat(
- (this._deflate as any)[kBuffers],
- (this._deflate as any)[kTotalLength]
- )
+ deflate.write(data)
+ deflate.flush(zlib.constants.Z_SYNC_FLUSH, () => {
+ // Cleanup or a zlib error can close the stream while work is pending.
+ if (this._deflate !== deflate) return
+ let result = bufferUtil.concat(state[kBuffers], state[kTotalLength])
+ if (fin) result = result.subarray(0, Math.max(0, result.length - TRAILER.length))
- if (this._maxPayload < 1 || data.length <= this._maxPayload) {
- callback(null, data)
- return
- }
+ state[kCallback] = undefined
+ state[kTotalLength] = 0
+ state[kBuffers] = []
+ if (fin && this.params?.[`${endpoint}_no_context_takeover`]) deflate.reset()
+ if (this._maxPayload > 0 && result.length > this._maxPayload) {
callback(new RangeError('Max payload size exceeded'), null)
- })
+ } else {
+ callback(null, result)
+ }
+ })
}
}
-function inflateOnError(this: PerMessageDeflate, err: Error): void {
- this[kPerMessageDeflate]![kCallback]!(err)
+function inflateOnError(this: zlib.InflateRaw, err: Error): void {
+ const state = this as any
+ const extension = state[kPerMessageDeflate] as PerMessageDeflate | undefined
+ if (extension) (extension as any)._inflate = null
+ const callback = state[kCallback] as ((error: Error) => void) | undefined
+ state[kCallback] = undefined
+ if (callback) callback(err)
}
-function inflateOnData(this: PerMessageDeflate, chunk: Buffer): void {
- this[kTotalLength]! += chunk.length
- this[kBuffers]!.push(chunk)
+function inflateOnData(this: zlib.InflateRaw, chunk: Buffer): void {
+ const state = this as any
+ state[kTotalLength] += chunk.length
+ state[kBuffers].push(chunk)
}
-function deflateOnError(this: PerMessageDeflate, err: Error): void {
- this[kPerMessageDeflate]![kCallback]!(err)
+function deflateOnError(this: zlib.DeflateRaw, err: Error): void {
+ const state = this as any
+ const extension = state[kPerMessageDeflate] as PerMessageDeflate | undefined
+ if (extension) (extension as any)._deflate = null
+ const callback = state[kCallback] as ((error: Error) => void) | undefined
+ state[kCallback] = undefined
+ if (callback) callback(err)
}
-function deflateOnData(this: PerMessageDeflate, chunk: Buffer): void {
- this[kTotalLength]! += chunk.length
- this[kBuffers]!.push(chunk)
+function deflateOnData(this: zlib.DeflateRaw, chunk: Buffer): void {
+ const state = this as any
+ state[kTotalLength] += chunk.length
+ state[kBuffers].push(chunk)
}
export default PerMessageDeflate
diff --git a/packages/network-debugger/src/diagnostics/doctor.test.ts b/packages/network-debugger/src/diagnostics/doctor.test.ts
new file mode 100644
index 0000000..008eeb7
--- /dev/null
+++ b/packages/network-debugger/src/diagnostics/doctor.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it, vi } from 'vitest'
+import type {
+ AdapterKind,
+ AdapterProbe,
+ CapabilityMap,
+ DebugAdapter,
+ Diagnostic
+} from '../adapters/types'
+import type { ConfigResolution, ResolvedNndConfig } from '../config'
+import { formatDoctorReport } from './format'
+import { runDoctor } from './doctor'
+
+const noCapabilities: CapabilityMap = {
+ http: false,
+ https: false,
+ fetch: false,
+ http2: false,
+ responseBody: false,
+ requestBody: false,
+ websocketLifecycle: false,
+ websocketFrames: false,
+ sseMessages: false,
+ initiator: false
+}
+
+const legacyCapabilities: CapabilityMap = {
+ ...noCapabilities,
+ http: true,
+ https: true,
+ fetch: true,
+ responseBody: true,
+ requestBody: true
+}
+
+function probe(
+ kind: AdapterKind,
+ available: boolean,
+ capabilities: CapabilityMap,
+ diagnostics: readonly Diagnostic[] = [],
+ autoSelectable = true
+): AdapterProbe {
+ return { kind, available, autoSelectable, capabilities, diagnostics }
+}
+
+function adapter(kind: AdapterKind, implementation: () => AdapterProbe): DebugAdapter {
+ return {
+ kind,
+ probe: vi.fn(implementation),
+ start: vi.fn(() => Promise.reject(new Error('doctor never starts adapters')))
+ }
+}
+
+function resolution(mode: ResolvedNndConfig['mode']): ConfigResolution {
+ return {
+ config: {
+ mode,
+ open: false,
+ wait: true,
+ watch: false,
+ runner: 'node',
+ inspector: { host: '127.0.0.1', port: 0 },
+ requiredCapabilities: [],
+ legacy: {}
+ },
+ sources: { env: [], cli: [] }
+ }
+}
+
+describe('doctor diagnostics', () => {
+ it('reports Auto fallback without treating the usable result as a failure', async () => {
+ const nativeDiagnostic: Diagnostic = {
+ code: 'NND_NATIVE_FLAG_REQUIRED',
+ level: 'error',
+ message: 'flag missing',
+ hint: 'enable it'
+ }
+ const report = await runDoctor({
+ nodeVersion: '24.16.0',
+ packageVersion: '1.2.3',
+ execArgv: [],
+ inspectorAvailable: true,
+ inspector: { url: () => undefined, open: () => undefined, close: () => undefined },
+ nativeAdapter: adapter('native', () =>
+ probe('native', false, noCapabilities, [nativeDiagnostic], false)
+ ),
+ legacyAdapter: adapter('legacy', () => probe('legacy', true, legacyCapabilities)),
+ resolve: vi.fn(async () => resolution('auto'))
+ })
+
+ expect(report.ok).toBe(true)
+ expect(report.selection).toMatchObject({ requested: 'auto', selected: 'legacy' })
+ expect(report.selection.fallbackReason).toMatchObject({ code: 'NND_AUTO_FALLBACK' })
+ expect(report.diagnostics.map((item) => item.code)).toEqual(
+ expect.arrayContaining([
+ 'NND_DOCTOR_NODE_VERSION',
+ 'NND_DOCTOR_PACKAGE_VERSION',
+ 'NND_DOCTOR_CONFIG_DEFAULTS',
+ 'NND_NATIVE_FLAG_REQUIRED',
+ 'NND_AUTO_FALLBACK',
+ 'NND_DOCTOR_SELECTED_LEGACY'
+ ])
+ )
+ })
+
+ it('fails forced Native selection with stable selection details', async () => {
+ const report = await runDoctor({
+ packageVersion: '1.2.3',
+ nativeAdapter: adapter('native', () => probe('native', false, noCapabilities)),
+ legacyAdapter: adapter('legacy', () => probe('legacy', true, legacyCapabilities)),
+ resolve: vi.fn(async () => resolution('native'))
+ })
+
+ expect(report.ok).toBe(false)
+ expect(report.selection).toMatchObject({
+ requested: 'native',
+ errorCode: 'NND_ADAPTER_UNAVAILABLE'
+ })
+ expect(report.diagnostics.at(-1)).toMatchObject({
+ code: 'NND_DOCTOR_SELECTION_FAILED',
+ level: 'error'
+ })
+ })
+
+ it('bounded-waits and retries a forced probe', async () => {
+ let calls = 0
+ let time = 0
+ const native = adapter('native', () => {
+ calls += 1
+ return calls <= 2
+ ? probe('native', false, noCapabilities)
+ : probe('native', true, legacyCapabilities)
+ })
+ const report = await runDoctor({
+ packageVersion: '1.2.3',
+ nativeAdapter: native,
+ legacyAdapter: adapter('legacy', () => probe('legacy', true, legacyCapabilities)),
+ resolve: vi.fn(async () => resolution('native')),
+ probeWaitMs: 100,
+ probeIntervalMs: 25,
+ now: () => time,
+ sleep: vi.fn(async (milliseconds) => {
+ time += milliseconds
+ })
+ })
+
+ expect(report.ok).toBe(true)
+ expect(report.selection.selected).toBe('native')
+ expect(calls).toBeGreaterThanOrEqual(3)
+ expect(time).toBeLessThanOrEqual(100)
+ })
+
+ it('waits before committing an Auto fallback when Native may become available', async () => {
+ let calls = 0
+ let time = 0
+ const native = adapter('native', () => {
+ calls += 1
+ return calls <= 2
+ ? probe('native', false, noCapabilities)
+ : probe('native', true, legacyCapabilities)
+ })
+ const report = await runDoctor({
+ packageVersion: '1.2.3',
+ nativeAdapter: native,
+ legacyAdapter: adapter('legacy', () => probe('legacy', true, legacyCapabilities)),
+ resolve: vi.fn(async () => resolution('auto')),
+ probeWaitMs: 100,
+ probeIntervalMs: 25,
+ now: () => time,
+ sleep: vi.fn(async (milliseconds) => {
+ time += milliseconds
+ })
+ })
+
+ expect(report.ok).toBe(true)
+ expect(report.selection.selected).toBe('native')
+ expect(time).toBe(25)
+ })
+
+ it('preserves config errors in JSON and human formats', async () => {
+ const report = await runDoctor({
+ packageVersion: '1.2.3',
+ nativeAdapter: adapter('native', () => probe('native', false, noCapabilities)),
+ legacyAdapter: adapter('legacy', () => probe('legacy', true, legacyCapabilities)),
+ resolve: vi.fn(async () => {
+ throw Object.assign(new Error('broken config'), { code: 'CUSTOM' })
+ })
+ })
+
+ expect(report.ok).toBe(false)
+ const json = JSON.parse(formatDoctorReport(report, true)) as typeof report
+ expect(json.schemaVersion).toBe(1)
+ expect(json.ok).toBe(false)
+ expect(formatDoctorReport(report)).toContain('NND_CONFIG_LOAD_FAILED')
+ })
+})
diff --git a/packages/network-debugger/src/diagnostics/doctor.ts b/packages/network-debugger/src/diagnostics/doctor.ts
new file mode 100644
index 0000000..fb5e8d0
--- /dev/null
+++ b/packages/network-debugger/src/diagnostics/doctor.ts
@@ -0,0 +1,335 @@
+import { readFileSync } from 'node:fs'
+import * as nodeInspector from 'node:inspector'
+import { dirname, parse, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { LegacyAdapter } from '../adapters/legacy'
+import {
+ NodeNativeAdapter,
+ OPTIONAL_NATIVE_NETWORK_METHODS,
+ REQUIRED_NATIVE_NETWORK_METHODS,
+ hasNativeInspectionFlag,
+ type NativeInspectorApi,
+ type NodeNativeAdapterDependencies
+} from '../adapters/node-native'
+import { AdapterSelector } from '../adapters/selector'
+import type { AdapterProbe, DebugAdapter, Diagnostic, NetworkCapability } from '../adapters/types'
+import {
+ NndConfigError,
+ resolveConfig,
+ type ConfigResolution,
+ type ResolveConfigOptions,
+ type ResolvedNndConfig
+} from '../config'
+
+export const DOCTOR_SCHEMA_VERSION = 1
+
+export interface DoctorNetworkMethods {
+ required: readonly string[]
+ optional: readonly string[]
+ available: readonly string[]
+ missingRequired: readonly string[]
+}
+
+export interface DoctorSelection {
+ requested: ResolvedNndConfig['mode']
+ selected?: 'native' | 'legacy'
+ fallbackReason?: Diagnostic
+ errorCode?: string
+ error?: string
+}
+
+export interface DoctorReport {
+ schemaVersion: typeof DOCTOR_SCHEMA_VERSION
+ ok: boolean
+ nodeVersion: string
+ packageVersion: string
+ inspectorAvailable: boolean
+ experimentalFlag: boolean
+ networkMethods: DoctorNetworkMethods
+ capabilities: Readonly>
+ native: AdapterProbe
+ config?: ConfigResolution
+ selection: DoctorSelection
+ diagnostics: readonly Diagnostic[]
+}
+
+export interface DoctorOptions {
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ config?: ResolveConfigOptions['cli']
+ configFile?: ResolveConfigOptions['configFile']
+ /** Retry a failed forced-mode probe for at most this many milliseconds. */
+ probeWaitMs?: number
+ probeIntervalMs?: number
+ nodeVersion?: string
+ packageVersion?: string
+ execArgv?: readonly string[]
+ inspector?: NativeInspectorApi | null
+ inspectorAvailable?: boolean
+ nativeAdapter?: DebugAdapter
+ legacyAdapter?: DebugAdapter
+ resolve?: (options?: ResolveConfigOptions) => Promise
+ sleep?: (milliseconds: number) => Promise
+ now?: () => number
+}
+
+function diagnostic(
+ code: string,
+ level: Diagnostic['level'],
+ message: string,
+ hint?: string,
+ details?: Readonly>
+): Diagnostic {
+ return {
+ code,
+ level,
+ message,
+ ...(hint ? { hint } : {}),
+ ...(details ? { details } : {})
+ }
+}
+
+function packageVersionFrom(start: string): string | undefined {
+ let directory = start
+ const root = parse(directory).root
+ while (directory !== root) {
+ try {
+ const value = JSON.parse(readFileSync(resolve(directory, 'package.json'), 'utf8')) as {
+ name?: unknown
+ version?: unknown
+ }
+ if (value.name === 'node-network-devtools' && typeof value.version === 'string') {
+ return value.version
+ }
+ } catch {
+ // Keep walking: installed and source layouts place this module at
+ // different depths.
+ }
+ directory = dirname(directory)
+ }
+ return undefined
+}
+
+export function detectPackageVersion(moduleUrl: string = import.meta.url): string {
+ try {
+ return packageVersionFrom(dirname(fileURLToPath(moduleUrl))) ?? 'unknown'
+ } catch {
+ return 'unknown'
+ }
+}
+
+function methodReport(inspector: NativeInspectorApi | null): DoctorNetworkMethods {
+ const network = inspector?.Network
+ const required = [...REQUIRED_NATIVE_NETWORK_METHODS]
+ const optional = [...OPTIONAL_NATIVE_NETWORK_METHODS]
+ const available = [...required, ...optional].filter(
+ (method) => typeof network?.[method as keyof typeof network] === 'function'
+ )
+ return {
+ required,
+ optional,
+ available,
+ missingRequired: required.filter((method) => !available.includes(method))
+ }
+}
+
+function configErrorDiagnostic(error: unknown): Diagnostic {
+ if (error instanceof NndConfigError) {
+ return diagnostic(
+ error.code,
+ 'error',
+ error.message,
+ 'Fix the configuration and rerun nnd doctor.',
+ {
+ ...error.details
+ }
+ )
+ }
+ return diagnostic(
+ 'NND_CONFIG_LOAD_FAILED',
+ 'error',
+ error instanceof Error ? error.message : String(error),
+ 'Fix the configuration and rerun nnd doctor.'
+ )
+}
+
+function selectionErrorCode(error: unknown): string {
+ if (typeof error === 'object' && error !== null && 'code' in error) {
+ return String((error as { code: unknown }).code)
+ }
+ return 'NND_DOCTOR_SELECTION_FAILED'
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+interface SelectionAttempt {
+ native: AdapterProbe
+ selection: DoctorSelection
+ selectionDiagnostic: Diagnostic
+}
+
+async function inspectSelection(
+ nativeAdapter: DebugAdapter,
+ legacyAdapter: DebugAdapter,
+ config: ResolvedNndConfig
+): Promise {
+ const options = {
+ mode: config.mode,
+ requiredCapabilities: config.requiredCapabilities,
+ inspector: config.inspector
+ } as const
+ const native = await nativeAdapter.probe(options)
+ try {
+ const selected = await new AdapterSelector([nativeAdapter, legacyAdapter]).select(options)
+ return {
+ native,
+ selection: {
+ requested: config.mode,
+ selected: selected.adapter.kind,
+ ...(selected.fallbackReason ? { fallbackReason: selected.fallbackReason } : {})
+ },
+ selectionDiagnostic: diagnostic(
+ `NND_DOCTOR_SELECTED_${selected.adapter.kind.toUpperCase()}`,
+ selected.fallbackReason ? 'warn' : 'info',
+ `Selected ${selected.adapter.kind} adapter${selected.fallbackReason ? ' via Auto fallback' : ''}.`,
+ undefined,
+ { requested: config.mode, selected: selected.adapter.kind }
+ )
+ }
+ } catch (error) {
+ const code = selectionErrorCode(error)
+ const message = errorMessage(error)
+ return {
+ native,
+ selection: {
+ requested: config.mode,
+ errorCode: code,
+ error: message
+ },
+ selectionDiagnostic: diagnostic(
+ 'NND_DOCTOR_SELECTION_FAILED',
+ 'error',
+ message,
+ config.mode === 'native'
+ ? 'Enable the experimental flag and use a supported Node.js runtime, or choose Auto/Legacy.'
+ : 'Review adapter diagnostics and required capabilities.',
+ { code, requested: config.mode }
+ )
+ }
+ }
+}
+
+const defaultSleep = (milliseconds: number) =>
+ new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))
+
+export async function runDoctor(options: DoctorOptions = {}): Promise {
+ const env = options.env ?? process.env
+ const nodeVersion = options.nodeVersion ?? process.versions.node
+ const packageVersion = options.packageVersion ?? detectPackageVersion()
+ const execArgv = options.execArgv ?? process.execArgv
+ const inspector =
+ options.inspector === undefined
+ ? (nodeInspector as unknown as NativeInspectorApi)
+ : options.inspector
+ const inspectorAvailable =
+ options.inspectorAvailable ?? (process.features?.inspector !== false && inspector !== null)
+ const methods = methodReport(inspector)
+ const diagnostics: Diagnostic[] = [
+ diagnostic('NND_DOCTOR_NODE_VERSION', 'info', `Node.js ${nodeVersion}.`, undefined, {
+ nodeVersion
+ }),
+ diagnostic(
+ 'NND_DOCTOR_PACKAGE_VERSION',
+ packageVersion === 'unknown' ? 'warn' : 'info',
+ `node-network-devtools ${packageVersion}.`,
+ packageVersion === 'unknown' ? 'Run doctor from an installed package.' : undefined,
+ { packageVersion }
+ )
+ ]
+
+ let resolution: ConfigResolution | undefined
+ try {
+ resolution = await (options.resolve ?? resolveConfig)({
+ cwd: options.cwd,
+ env,
+ cli: options.config,
+ configFile: options.configFile
+ })
+ diagnostics.push(
+ resolution.sources.configFile
+ ? diagnostic(
+ 'NND_DOCTOR_CONFIG_LOADED',
+ 'info',
+ `Loaded configuration from ${resolution.sources.configFile}.`,
+ undefined,
+ { sources: resolution.sources }
+ )
+ : diagnostic(
+ 'NND_DOCTOR_CONFIG_DEFAULTS',
+ 'info',
+ 'No config file found; using environment and defaults.',
+ undefined,
+ { sources: resolution.sources }
+ )
+ )
+ } catch (error) {
+ diagnostics.push(configErrorDiagnostic(error))
+ }
+
+ const effectiveConfig: ResolvedNndConfig = resolution?.config ?? {
+ mode: options.config?.mode ?? 'auto',
+ open: false,
+ wait: true,
+ watch: false,
+ runner: 'node',
+ inspector: { host: '127.0.0.1', port: 0 },
+ requiredCapabilities: options.config?.requiredCapabilities ?? [],
+ legacy: {}
+ }
+ const nativeDependencies: NodeNativeAdapterDependencies = {
+ inspector,
+ inspectorAvailable,
+ execArgv,
+ nodeVersion
+ }
+ const nativeAdapter = options.nativeAdapter ?? new NodeNativeAdapter(nativeDependencies)
+ const legacyAdapter = options.legacyAdapter ?? new LegacyAdapter()
+ const sleep = options.sleep ?? defaultSleep
+ const now = options.now ?? Date.now
+ const waitMs = Math.max(0, options.probeWaitMs ?? 0)
+ const interval = Math.max(1, options.probeIntervalMs ?? 100)
+ const deadline = now() + waitMs
+ let attempt = await inspectSelection(nativeAdapter, legacyAdapter, effectiveConfig)
+
+ const shouldRetry = () =>
+ !attempt.selection.selected ||
+ (effectiveConfig.mode === 'auto' &&
+ attempt.selection.selected === 'legacy' &&
+ attempt.selection.fallbackReason !== undefined)
+
+ while (shouldRetry() && now() < deadline) {
+ await sleep(Math.min(interval, Math.max(0, deadline - now())))
+ attempt = await inspectSelection(nativeAdapter, legacyAdapter, effectiveConfig)
+ }
+
+ diagnostics.push(...attempt.native.diagnostics)
+ if (attempt.selection.fallbackReason) diagnostics.push(attempt.selection.fallbackReason)
+ diagnostics.push(attempt.selectionDiagnostic)
+
+ return {
+ schemaVersion: DOCTOR_SCHEMA_VERSION,
+ ok: Boolean(resolution && attempt.selection.selected),
+ nodeVersion,
+ packageVersion,
+ inspectorAvailable,
+ experimentalFlag: hasNativeInspectionFlag(execArgv),
+ networkMethods: methods,
+ capabilities: attempt.native.capabilities,
+ native: attempt.native,
+ ...(resolution ? { config: resolution } : {}),
+ selection: attempt.selection,
+ diagnostics
+ }
+}
diff --git a/packages/network-debugger/src/diagnostics/format.ts b/packages/network-debugger/src/diagnostics/format.ts
new file mode 100644
index 0000000..da1f671
--- /dev/null
+++ b/packages/network-debugger/src/diagnostics/format.ts
@@ -0,0 +1,33 @@
+import type { Diagnostic } from '../adapters/types'
+import type { DoctorReport } from './doctor'
+
+function mark(level: Diagnostic['level']): string {
+ if (level === 'error') return 'x'
+ if (level === 'warn') return '!'
+ return 'i'
+}
+
+export function formatDoctorReport(report: DoctorReport, json = false): string {
+ if (json) return `${JSON.stringify(report, null, 2)}\n`
+
+ const selected = report.selection.selected ?? 'none'
+ const capabilities = Object.entries(report.capabilities)
+ .filter(([, supported]) => supported)
+ .map(([capability]) => capability)
+ const lines = [
+ `Node Network Devtools doctor (${report.ok ? 'ok' : 'failed'})`,
+ `Node: ${report.nodeVersion}`,
+ `Package: ${report.packageVersion}`,
+ `Inspector: ${report.inspectorAvailable ? 'available' : 'unavailable'}`,
+ `Experimental flag: ${report.experimentalFlag ? 'enabled' : 'missing'}`,
+ `Network methods: ${report.networkMethods.available.length} available, ${report.networkMethods.missingRequired.length} required missing`,
+ `Selection: ${report.selection.requested} -> ${selected}`,
+ `Native capabilities: ${capabilities.length ? capabilities.join(', ') : 'none'}`,
+ '',
+ ...report.diagnostics.map((item) => {
+ const hint = item.hint ? ` Hint: ${item.hint}` : ''
+ return `[${mark(item.level)}] ${item.code}: ${item.message}${hint}`
+ })
+ ]
+ return `${lines.join('\n')}\n`
+}
diff --git a/packages/network-debugger/src/diagnostics/index.ts b/packages/network-debugger/src/diagnostics/index.ts
new file mode 100644
index 0000000..9fca59d
--- /dev/null
+++ b/packages/network-debugger/src/diagnostics/index.ts
@@ -0,0 +1,2 @@
+export * from './doctor'
+export * from './format'
diff --git a/packages/network-debugger/src/fork/devtool/index.test.ts b/packages/network-debugger/src/fork/devtool/index.test.ts
index ca5a2bf..01cebc3 100644
--- a/packages/network-debugger/src/fork/devtool/index.test.ts
+++ b/packages/network-debugger/src/fork/devtool/index.test.ts
@@ -1,533 +1,371 @@
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
-
-// 使用 vi.hoisted 确保变量在 mock 提升时可用
-const {
- mockWsServerInstance,
- mockWsSocketInstance,
- mockOpen,
- mockWsDebuggerInstance,
- wsServerHandlers,
- wsSocketHandlers,
- wsDebuggerHandlers,
- MockServer,
- MockWebSocket
-} = vi.hoisted(() => {
- const wsServerHandlers = new Map void)[]>()
- const wsSocketHandlers = new Map void)[]>()
- const wsDebuggerHandlers = new Map void)[]>()
-
- const mockWsServerInstance = {
- on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
- if (!wsServerHandlers.has(event)) {
- wsServerHandlers.set(event, [])
+import { once } from 'node:events'
+import { setTimeout as delay } from 'node:timers/promises'
+import { afterEach, describe, expect, test } from 'vitest'
+import { WebSocket } from 'ws'
+import {
+ CDP_ERROR_CODES,
+ DevtoolServer,
+ DevtoolServerClosedError,
+ MAX_BUFFERED_EVENT_BYTES,
+ MAX_BUFFERED_EVENTS
+} from './index'
+
+type JsonMessage = Record
+
+class ProtocolClient {
+ readonly socket: WebSocket
+ private readonly messages: JsonMessage[] = []
+ private readonly waiters: Array<{
+ predicate(message: JsonMessage): boolean
+ resolve(message: JsonMessage): void
+ reject(error: Error): void
+ timer: ReturnType
+ }> = []
+
+ private constructor(url: string) {
+ this.socket = new WebSocket(url)
+ this.socket.on('message', (raw) => {
+ const message = JSON.parse(raw.toString()) as JsonMessage
+ const waiterIndex = this.waiters.findIndex((waiter) => waiter.predicate(message))
+ if (waiterIndex >= 0) {
+ const [waiter] = this.waiters.splice(waiterIndex, 1)
+ clearTimeout(waiter.timer)
+ waiter.resolve(message)
+ } else {
+ this.messages.push(message)
}
- wsServerHandlers.get(event)!.push(handler)
- }),
- close: vi.fn()
- }
-
- const mockWsSocketInstance = {
- on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
- if (!wsSocketHandlers.has(event)) {
- wsSocketHandlers.set(event, [])
- }
- wsSocketHandlers.get(event)!.push(handler)
- }),
- send: vi.fn(),
- close: vi.fn()
+ })
}
- const mockWsDebuggerInstance = {
- on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
- if (!wsDebuggerHandlers.has(event)) {
- wsDebuggerHandlers.set(event, [])
- }
- wsDebuggerHandlers.get(event)!.push(handler)
- }),
- send: vi.fn(),
- close: vi.fn()
+ static async connect(url: string) {
+ const client = new ProtocolClient(url)
+ await once(client.socket, 'open')
+ return client
}
- // 使用 class 语法创建 mock 构造函数
- class MockServer {
- constructor() {
- return mockWsServerInstance
- }
+ send(message: unknown) {
+ this.socket.send(JSON.stringify(message))
}
- class MockWebSocket {
- constructor() {
- return mockWsDebuggerInstance
- }
+ sendRaw(message: string) {
+ this.socket.send(message)
}
- const mockOpen = vi.fn()
-
- return {
- mockWsServerInstance,
- mockWsSocketInstance,
- mockOpen,
- mockWsDebuggerInstance,
- wsServerHandlers,
- wsSocketHandlers,
- wsDebuggerHandlers,
- MockServer,
- MockWebSocket
+ next(
+ predicate: (message: JsonMessage) => boolean = () => true,
+ timeoutMs = 2_000
+ ): Promise {
+ const index = this.messages.findIndex(predicate)
+ if (index >= 0) return Promise.resolve(this.messages.splice(index, 1)[0])
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ const waiterIndex = this.waiters.findIndex((waiter) => waiter.timer === timer)
+ if (waiterIndex >= 0) this.waiters.splice(waiterIndex, 1)
+ reject(new Error('Timed out waiting for a CDP message.'))
+ }, timeoutMs)
+ this.waiters.push({ predicate, resolve, reject, timer })
+ })
}
-})
-// Mock ws 模块
-vi.mock('ws', () => {
- return {
- Server: MockServer,
- WebSocket: MockWebSocket
+ async close() {
+ if (this.socket.readyState === WebSocket.CLOSED) return
+ const closed = once(this.socket, 'close')
+ this.socket.close()
+ await closed
}
+}
+
+const servers = new Set()
+const clients = new Set()
+
+async function createServer(options: Partial[0]> = {}) {
+ const server = new DevtoolServer({ port: 0, ...options })
+ servers.add(server)
+ const target = await server.ready
+ return { server, target }
+}
+
+async function connect(url: string) {
+ const client = await ProtocolClient.connect(url)
+ clients.add(client)
+ return client
+}
+
+afterEach(async () => {
+ await Promise.all([...clients].map((client) => client.close().catch(() => undefined)))
+ clients.clear()
+ await Promise.all([...servers].map((server) => server.close()))
+ servers.clear()
})
-// Mock open 模块
-vi.mock('open', () => {
- return {
- default: mockOpen,
- apps: {
- chrome: 'google-chrome'
- }
- }
-})
+describe('Legacy discoverable CDP target', () => {
+ test('binds port 0 before publishing one consistent loopback target', async () => {
+ const server = new DevtoolServer({ port: 0 })
+ servers.add(server)
+ expect(server.target).toBeUndefined()
+
+ const target = await server.ready
+ const discovery = new URL(target.discoveryUrl)
+ expect(discovery.hostname).toBe('127.0.0.1')
+ expect(Number(discovery.port)).toBeGreaterThan(0)
+ expect(target.webSocketDebuggerUrl).toMatch(
+ new RegExp(`^ws://127\\.0\\.0\\.1:${discovery.port}/devtools/page/${target.id}$`)
+ )
+ expect(server.target).toEqual(target)
+
+ const list = await fetch(target.discoveryUrl).then((response) => response.json())
+ const alias = await fetch(new URL('/json', target.discoveryUrl)).then((response) =>
+ response.json()
+ )
+ const version = await fetch(new URL('/json/version', target.discoveryUrl)).then((response) =>
+ response.json()
+ )
+ const protocol = await fetch(new URL('/json/protocol', target.discoveryUrl)).then((response) =>
+ response.json()
+ )
+
+ expect(list).toEqual([target])
+ expect(alias).toEqual([target])
+ expect(version).toMatchObject({
+ Browser: 'node-network-devtools/2',
+ 'Protocol-Version': '1.3',
+ webSocketDebuggerUrl: target.webSocketDebuggerUrl
+ })
+ expect(protocol.version).toEqual({ major: '1', minor: '3' })
+ expect(protocol.domains.map((domain: { domain: string }) => domain.domain)).toEqual(
+ expect.arrayContaining(['Network', 'Debugger', 'Runtime', 'Schema'])
+ )
-// Mock common 模块
-vi.mock('../../common', () => ({
- IS_DEV_MODE: false,
- REMOTE_DEBUGGER_PORT: 9333
-}))
-
-// Mock utils 模块
-vi.mock('../../utils', () => ({
- log: vi.fn()
-}))
-
-describe('fork/devtool/index.ts', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- wsServerHandlers.clear()
- wsSocketHandlers.clear()
- wsDebuggerHandlers.clear()
+ const notFound = await fetch(new URL('/not-a-target', target.discoveryUrl))
+ expect(notFound.status).toBe(404)
})
- afterEach(() => {
- vi.restoreAllMocks()
+ test('accepts upgrades only on the published WebSocket path', async () => {
+ const { target } = await createServer()
+ const wrongUrl = new URL(target.webSocketDebuggerUrl)
+ wrongUrl.pathname = '/devtools/page/wrong-target'
+ const socket = new WebSocket(wrongUrl)
+ const outcome = Promise.race([
+ once(socket, 'open').then(() => 'open'),
+ once(socket, 'error').then(() => 'error'),
+ once(socket, 'unexpected-response').then(() => 'rejected')
+ ])
+ await expect(outcome).resolves.not.toBe('open')
+ socket.terminate()
})
- describe('DevtoolServer 类', () => {
- describe('构造函数', () => {
- test('创建 WebSocket Server 并监听指定端口', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- expect(server).toBeDefined()
- })
-
- test('监听 listening 事件', async () => {
- const { DevtoolServer } = await import('./index')
-
- new DevtoolServer({ port: 5271 })
-
- expect(mockWsServerInstance.on).toHaveBeenCalledWith('listening', expect.any(Function))
- })
-
- test('监听 connection 事件', async () => {
- const { DevtoolServer } = await import('./index')
-
- new DevtoolServer({ port: 5271 })
-
- expect(mockWsServerInstance.on).toHaveBeenCalledWith('connection', expect.any(Function))
- })
-
- test('autoOpenDevtool 默认为 true', async () => {
- const { DevtoolServer } = await import('./index')
-
- new DevtoolServer({ port: 5271 })
-
- // 触发 listening 事件
- const listeningHandler = wsServerHandlers.get('listening')?.[0]
- if (listeningHandler) {
- listeningHandler()
- }
-
- // 由于 autoOpenDevtool 默认为 true,应该调用 open
- // 但由于 IS_DEV_MODE 被 mock 为 false,会尝试打开浏览器
- expect(mockOpen).toHaveBeenCalled()
- })
-
- test('autoOpenDevtool 为 false 时不自动打开', async () => {
- vi.clearAllMocks()
- wsServerHandlers.clear()
-
- const { DevtoolServer } = await import('./index')
-
- new DevtoolServer({ port: 5271, autoOpenDevtool: false })
-
- // 触发 listening 事件
- const listeningHandler = wsServerHandlers.get('listening')?.[0]
- if (listeningHandler) {
- listeningHandler()
- }
-
- // autoOpenDevtool 为 false,不应该调用 open
- expect(mockOpen).not.toHaveBeenCalled()
- })
-
- test('onConnect 回调在连接时被调用', async () => {
- const { DevtoolServer } = await import('./index')
- const onConnect = vi.fn()
-
- new DevtoolServer({ port: 5271, onConnect })
-
- // 触发 connection 事件
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- expect(onConnect).toHaveBeenCalled()
- })
-
- test('onClose 回调在连接关闭时被调用', async () => {
- const { DevtoolServer } = await import('./index')
- const onClose = vi.fn()
-
- new DevtoolServer({ port: 5271, onClose })
-
- // 触发 connection 事件
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- // 触发 socket close 事件
- const closeHandler = wsSocketHandlers.get('close')?.[0]
- if (closeHandler) {
- closeHandler()
- }
+ test('uses a parent-supplied stable target identity in discovery and WebSocket URLs', async () => {
+ const { target } = await createServer({ targetId: 'stable.parent-target_1' })
+ expect(target.id).toBe('stable.parent-target_1')
+ expect(new URL(target.webSocketDebuggerUrl).pathname).toBe(
+ '/devtools/page/stable.parent-target_1'
+ )
+ })
- expect(onClose).toHaveBeenCalled()
- })
+ test('single-casts same-id async command responses to their source clients', async () => {
+ const { server, target } = await createServer()
+ server.on(async (_error, message, context) => {
+ if (!message || !('method' in message) || message.method !== 'Test.echo' || !context) {
+ return false
+ }
+ const token = String(message.params?.token)
+ if (token === 'slow') await delay(30)
+ await context.result({ token })
+ return true
})
- describe('消息处理', () => {
- test('接收到消息时通知所有监听器', async () => {
- const { DevtoolServer } = await import('./index')
- const listener = vi.fn()
-
- const server = new DevtoolServer({ port: 5271 })
- server.on(listener)
+ const first = await connect(target.webSocketDebuggerUrl)
+ const second = await connect(target.webSocketDebuggerUrl)
+ first.send({ id: 7, method: 'Test.echo', params: { token: 'slow' } })
+ second.send({ id: 7, method: 'Test.echo', params: { token: 'fast' } })
- // 触发 connection 事件
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- // 触发 message 事件
- const messageHandler = wsSocketHandlers.get('message')?.[0]
- const testMessage = { method: 'Network.enable', params: {} }
- if (messageHandler) {
- messageHandler(Buffer.from(JSON.stringify(testMessage)))
- }
-
- expect(listener).toHaveBeenCalledWith(null, testMessage)
- })
-
- test('接收到错误时通知所有监听器', async () => {
- const { DevtoolServer } = await import('./index')
- const listener = vi.fn()
-
- const server = new DevtoolServer({ port: 5271 })
- server.on(listener)
-
- // 触发 connection 事件
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- // 触发 error 事件
- const errorHandler = wsSocketHandlers.get('error')?.[0]
- const testError = new Error('WebSocket error')
- if (errorHandler) {
- errorHandler(testError)
- }
-
- expect(listener).toHaveBeenCalledWith(testError)
- })
+ await expect(second.next((message) => message.id === 7)).resolves.toEqual({
+ id: 7,
+ result: { token: 'fast' }
})
-
- describe('send 方法', () => {
- test('发送消息到 WebSocket', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- // 触发 connection 事件以建立连接
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- const message = { method: 'Network.requestWillBeSent', params: { requestId: '1' } }
- await server.send(message)
-
- expect(mockWsSocketInstance.send).toHaveBeenCalledWith(JSON.stringify(message))
- })
-
- test('发送响应消息', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- // 触发 connection 事件
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- const response = { id: '1', result: { body: 'test' } }
- await server.send(response)
-
- expect(mockWsSocketInstance.send).toHaveBeenCalledWith(JSON.stringify(response))
- })
-
- test('发送错误响应消息', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- // 触发 connection 事件
- const connectionHandler = wsServerHandlers.get('connection')?.[0]
- if (connectionHandler) {
- connectionHandler(mockWsSocketInstance)
- }
-
- const errorResponse = { id: '2', error: { code: -32601, message: 'Method not found' } }
- await server.send(errorResponse)
-
- expect(mockWsSocketInstance.send).toHaveBeenCalledWith(JSON.stringify(errorResponse))
- })
+ await expect(first.next((message) => message.id === 7)).resolves.toEqual({
+ id: 7,
+ result: { token: 'slow' }
})
+ })
- describe('close 方法', () => {
- test('关闭 WebSocket Server', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
- server.close()
-
- expect(mockWsServerInstance.close).toHaveBeenCalled()
- })
-
- test('关闭浏览器进程(如果存在)', async () => {
- const { DevtoolServer } = await import('./index')
-
- const mockBrowserProcess = { kill: vi.fn() }
- mockOpen.mockResolvedValue(mockBrowserProcess)
-
- const server = new DevtoolServer({ port: 5271 })
-
- // 触发 listening 事件以打开浏览器
- const listeningHandler = wsServerHandlers.get('listening')?.[0]
- if (listeningHandler) {
- listeningHandler()
- }
-
- // 等待 open 完成
- await vi.waitFor(() => {
- expect(mockOpen).toHaveBeenCalled()
- })
-
- // 等待异步操作完成
- await new Promise((resolve) => setTimeout(resolve, 0))
-
- server.close()
-
- expect(mockWsServerInstance.close).toHaveBeenCalled()
- })
+ test('preserves legacy devtool.send response routing across async handler work', async () => {
+ const { server, target } = await createServer()
+ server.on(async (_error, message) => {
+ if (!message || !('method' in message) || message.method !== 'Test.legacyReply') return false
+ await delay(Number(message.params?.delay ?? 0))
+ await server.send({ id: message.id!, result: { owner: message.params?.owner } })
+ return true
})
+ const first = await connect(target.webSocketDebuggerUrl)
+ const second = await connect(target.webSocketDebuggerUrl)
- describe('open 方法', () => {
- test('在开发模式下不打开浏览器', async () => {
- // 重新 mock common 模块为开发模式
- vi.doMock('../../common', () => ({
- IS_DEV_MODE: true,
- REMOTE_DEBUGGER_PORT: 9333
- }))
-
- vi.clearAllMocks()
-
- // 重新导入模块
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271, autoOpenDevtool: false })
- await server.open()
-
- // 在开发模式下不应该调用 open
- // 注意:由于模块缓存,这个测试可能需要特殊处理
- })
-
- test('在非开发模式下打开 Chrome DevTools', async () => {
- vi.clearAllMocks()
+ first.send({ id: 'same', method: 'Test.legacyReply', params: { owner: 'first', delay: 20 } })
+ second.send({ id: 'same', method: 'Test.legacyReply', params: { owner: 'second' } })
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271, autoOpenDevtool: false })
- await server.open()
-
- expect(mockOpen).toHaveBeenCalledWith(
- expect.stringContaining('devtools://devtools/bundled/inspector.html'),
- expect.objectContaining({
- app: expect.objectContaining({
- name: 'google-chrome'
- }),
- wait: true
- })
- )
- })
-
- test('打开失败时输出警告但不抛出错误', async () => {
- vi.clearAllMocks()
-
- mockOpen.mockRejectedValue(new Error('Failed to open'))
- const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
-
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271, autoOpenDevtool: false })
- await server.open()
-
- expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Open devtools failed'))
-
- consoleWarnSpy.mockRestore()
- })
+ expect(await second.next((message) => message.id === 'same')).toEqual({
+ id: 'same',
+ result: { owner: 'second' }
})
+ expect(await first.next((message) => message.id === 'same')).toEqual({
+ id: 'same',
+ result: { owner: 'first' }
+ })
+ })
- describe('继承 BaseDevtoolServer', () => {
- test('继承 timestamp 属性', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- expect(server.timestamp).toBe(0)
- })
-
- test('继承 getTimestamp 方法', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- expect(typeof server.getTimestamp).toBe('function')
- expect(typeof server.getTimestamp()).toBe('number')
- })
-
- test('继承 updateTimestamp 方法', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- expect(typeof server.updateTimestamp).toBe('function')
- })
-
- test('继承 on 方法', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
- const listener = vi.fn()
-
- server.on(listener)
-
- expect(server.listeners).toContain(listener)
- })
+ test('broadcasts events to every connected frontend', async () => {
+ const { server, target } = await createServer()
+ const first = await connect(target.webSocketDebuggerUrl)
+ const second = await connect(target.webSocketDebuggerUrl)
+ first.send({ id: 1, method: 'Network.enable' })
+ second.send({ id: 2, method: 'Network.enable' })
+ await first.next((message) => message.id === 1)
+ await second.next((message) => message.id === 2)
+ const event = {
+ method: 'Network.requestWillBeSent',
+ params: { requestId: 'broadcast-request' }
+ }
- test('继承 listeners 属性', async () => {
- const { DevtoolServer } = await import('./index')
+ await server.send(event)
+ await expect(first.next((message) => message.method === event.method)).resolves.toEqual(event)
+ await expect(second.next((message) => message.method === event.method)).resolves.toEqual(event)
+ })
- const server = new DevtoolServer({ port: 5271 })
+ test('sends Network events only to clients that enabled the domain', async () => {
+ const { server, target } = await createServer()
+ const enabled = await connect(target.webSocketDebuggerUrl)
+ const disabled = await connect(target.webSocketDebuggerUrl)
+ enabled.send({ id: 'enable', method: 'Network.enable' })
+ await enabled.next((message) => message.id === 'enable')
- expect(Array.isArray(server.listeners)).toBe(true)
- })
+ await server.send({
+ method: 'Network.loadingFinished',
+ params: { requestId: 'enabled-only' }
})
+ expect(
+ await enabled.next((message) => message.params?.requestId === 'enabled-only')
+ ).toMatchObject({
+ method: 'Network.loadingFinished'
+ })
+ await expect(
+ disabled.next((message) => message.params?.requestId === 'enabled-only', 100)
+ ).rejects.toThrow('Timed out')
+
+ enabled.send({ id: 'disable', method: 'Network.disable' })
+ await enabled.next((message) => message.id === 'disable')
+ await server.send({ method: 'Network.loadingFinished', params: { requestId: 'disabled-now' } })
+ await expect(
+ enabled.next((message) => message.params?.requestId === 'disabled-now', 100)
+ ).rejects.toThrow('Timed out')
+ }, 6_000)
+
+ test('returns standard results/errors for zero, string, unknown, invalid and thrown commands', async () => {
+ const { server, target } = await createServer()
+ server.on(async (_error, message, context) => {
+ if (!message || !('method' in message) || !context) return false
+ if (message.method === 'Test.fail') throw new Error('handler exploded')
+ if (message.method === 'Test.applicationError') {
+ await context.error(CDP_ERROR_CODES.SERVER_ERROR, 'request body unavailable')
+ return true
+ }
+ return false
+ })
+ const client = await connect(target.webSocketDebuggerUrl)
- describe('IDevtoolServer 接口实现', () => {
- test('实现 send 方法', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271 })
-
- expect(typeof server.send).toBe('function')
- })
-
- test('实现 close 方法', async () => {
- const { DevtoolServer } = await import('./index')
+ client.send({ id: 0, method: 'Network.enable', params: {} })
+ expect(await client.next((message) => message.id === 0)).toEqual({ id: 0, result: {} })
- const server = new DevtoolServer({ port: 5271 })
+ client.send({ id: 'debugger', method: 'Debugger.enable', params: {} })
+ expect(await client.next((message) => message.id === 'debugger')).toMatchObject({
+ id: 'debugger',
+ result: { debuggerId: target.id }
+ })
- expect(typeof server.close).toBe('function')
- })
+ client.send({ id: 2, method: 'Missing.command', params: {} })
+ expect(await client.next((message) => message.id === 2)).toMatchObject({
+ id: 2,
+ error: { code: CDP_ERROR_CODES.METHOD_NOT_FOUND }
+ })
- test('实现 open 方法', async () => {
- const { DevtoolServer } = await import('./index')
+ client.send({ id: 3, method: 'Network.enable', params: [] })
+ expect(await client.next((message) => message.id === 3)).toMatchObject({
+ id: 3,
+ error: { code: CDP_ERROR_CODES.INVALID_PARAMS }
+ })
- const server = new DevtoolServer({ port: 5271 })
+ client.send({ id: 4, method: 'Test.fail' })
+ expect(await client.next((message) => message.id === 4)).toMatchObject({
+ id: 4,
+ error: { code: CDP_ERROR_CODES.INTERNAL_ERROR, message: 'handler exploded' }
+ })
- expect(typeof server.open).toBe('function')
- })
+ client.send({ id: 5, method: 'Test.applicationError' })
+ expect(await client.next((message) => message.id === 5)).toMatchObject({
+ id: 5,
+ error: { code: CDP_ERROR_CODES.SERVER_ERROR, message: 'request body unavailable' }
})
})
- describe('DevtoolServerInitOptions 接口', () => {
- test('port 是必需的', async () => {
- const { DevtoolServer } = await import('./index')
+ test('survives malformed JSON and continues serving commands', async () => {
+ const { target } = await createServer()
+ const client = await connect(target.webSocketDebuggerUrl)
+ client.sendRaw('{ definitely not json')
- // 这个测试主要验证类型,运行时只需确保可以创建实例
- const server = new DevtoolServer({ port: 5271 })
- expect(server).toBeDefined()
+ expect(await client.next((message) => message.id === null)).toMatchObject({
+ error: { code: CDP_ERROR_CODES.INVALID_REQUEST }
})
- test('autoOpenDevtool 是可选的', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server1 = new DevtoolServer({ port: 5271 })
- const server2 = new DevtoolServer({ port: 5272, autoOpenDevtool: true })
- const server3 = new DevtoolServer({ port: 5273, autoOpenDevtool: false })
-
- expect(server1).toBeDefined()
- expect(server2).toBeDefined()
- expect(server3).toBeDefined()
- })
+ client.send({ id: 9, method: 'Runtime.enable' })
+ expect(await client.next((message) => message.id === 9)).toEqual({ id: 9, result: {} })
+ })
- test('onConnect 是可选的', async () => {
- const { DevtoolServer } = await import('./index')
+ test('bounds no-client history and replays it after enable on reconnect', async () => {
+ const { server, target } = await createServer()
+ for (let index = 0; index < MAX_BUFFERED_EVENTS + 50; index += 1) {
+ await server.send({
+ method: 'Network.requestWillBeSent',
+ params: { requestId: `request-${index}` }
+ })
+ }
+ expect(server.bufferedEventCount).toBe(MAX_BUFFERED_EVENTS)
+ expect(server.bufferedEventBytes).toBeLessThanOrEqual(MAX_BUFFERED_EVENT_BYTES)
+ expect(server.clientCount).toBe(0)
+
+ const first = await connect(target.webSocketDebuggerUrl)
+ first.send({ id: 1, method: 'Network.enable' })
+ expect(await first.next((message) => message.id === 1)).toEqual({ id: 1, result: {} })
+ expect(await first.next((message) => message.params?.requestId === 'request-50')).toMatchObject(
+ {
+ method: 'Network.requestWillBeSent'
+ }
+ )
+ await first.close()
+ clients.delete(first)
- const server = new DevtoolServer({ port: 5271, onConnect: () => {} })
- expect(server).toBeDefined()
+ await server.send({
+ method: 'Network.loadingFinished',
+ params: { requestId: 'after-refresh' }
})
-
- test('onClose 是可选的', async () => {
- const { DevtoolServer } = await import('./index')
-
- const server = new DevtoolServer({ port: 5271, onClose: () => {} })
- expect(server).toBeDefined()
+ const refreshed = await connect(target.webSocketDebuggerUrl)
+ refreshed.send({ id: 'enable-again', method: 'Network.enable' })
+ expect(await refreshed.next((message) => message.id === 'enable-again')).toEqual({
+ id: 'enable-again',
+ result: {}
})
+ expect(
+ await refreshed.next((message) => message.params?.requestId === 'after-refresh')
+ ).toMatchObject({ method: 'Network.loadingFinished' })
})
- describe('模块导出', () => {
- test('导出 DevtoolServer 类', async () => {
- const module = await import('./index')
- expect(module.DevtoolServer).toBeDefined()
- })
+ test('close rejects an explicit pending client waiter, clears history and releases the port', async () => {
+ const { server, target } = await createServer()
+ await server.send({ method: 'Network.loadingFinished', params: { requestId: 'buffered' } })
+ const pending = server.waitForClient()
+ const discoveryUrl = target.discoveryUrl
- test('重新导出 type.ts 中的类型', async () => {
- const module = await import('./index')
- // BaseDevtoolServer 应该通过 export * from './type' 导出
- expect(module.BaseDevtoolServer).toBeDefined()
- })
+ await server.close()
+ await expect(pending).rejects.toBeInstanceOf(DevtoolServerClosedError)
+ expect(server.bufferedEventCount).toBe(0)
+ await expect(fetch(discoveryUrl)).rejects.toThrow()
})
})
diff --git a/packages/network-debugger/src/fork/devtool/index.ts b/packages/network-debugger/src/fork/devtool/index.ts
index 62c2fbb..f2d13fa 100644
--- a/packages/network-debugger/src/fork/devtool/index.ts
+++ b/packages/network-debugger/src/fork/devtool/index.ts
@@ -1,141 +1,737 @@
-import { Server, WebSocket } from 'ws'
-import open, { apps } from 'open'
-import { type ChildProcess } from 'child_process'
-import { IS_DEV_MODE } from '../../common'
-import { REMOTE_DEBUGGER_PORT } from '../../common'
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { randomUUID } from 'node:crypto'
+import { createServer, type Server as HttpServer, type ServerResponse } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import type { Duplex } from 'node:stream'
+import { WebSocket, WebSocketServer, type RawData } from 'ws'
+import type { DevtoolsTarget } from '../../adapters/types'
+import type { CdpId } from '../../legacy-bridge/contracts'
import { log } from '../../utils'
-import { BaseDevtoolServer, DevtoolMessage } from './type'
+import {
+ BaseDevtoolServer,
+ type DevtoolCommandContext,
+ type DevtoolErrorResponse,
+ type DevtoolMessage,
+ type DevtoolMessageRequest,
+ type DevtoolMessageResponse
+} from './type'
+
+const LOOPBACK_HOST = '127.0.0.1'
+const PROTOCOL_VERSION = '1.3'
+export const MAX_BUFFERED_EVENTS = 1_000
+export const MAX_BUFFERED_EVENT_BYTES = 10 * 1024 * 1024
+
+export const CDP_ERROR_CODES = Object.freeze({
+ INVALID_REQUEST: -32600,
+ METHOD_NOT_FOUND: -32601,
+ INVALID_PARAMS: -32602,
+ INTERNAL_ERROR: -32603,
+ SERVER_ERROR: -32000
+})
export interface DevtoolServerInitOptions {
+ /** Bind port. `0` asks the operating system for a free port. */
port: number
+ /** Only 127.0.0.1 is accepted; the endpoint is never exposed remotely. */
+ host?: '127.0.0.1'
+ /** Stable identity supplied by the parent so a restarted child keeps its URL. */
+ targetId?: string
+ title?: string
+ /** @deprecated Browser launching is owned by the caller. */
autoOpenDevtool?: boolean
onConnect?: () => void
onClose?: () => void
}
export interface IDevtoolServer {
- send(message: DevtoolMessage): Promise
- close(): void
- open(): Promise