# NV8 **Repository Path**: tuling-python/nv8 ## Basic Information - **Project Name**: NV8 - **Description**: 一个本地的浏览器运行时(基于edge浏览器) - **Primary Language**: Unknown - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 4 - **Forks**: 1 - **Created**: 2026-08-31 - **Last Updated**: 2026-09-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # NV8 一个零依赖的 Node.js 浏览器运行时模拟与 Web 采集框架。 NV8 在 Node 进程里重建一个与真实 Microsoft Edge 无法区分的 JavaScript 执行环境, 用来运行目标站点的前端代码,从中恢复请求签名、令牌与协议行为;同时提供一套完整的 采集调度层,把恢复出来的协议变成可持续运行的数据采集。 **许可证:木兰宽松许可证,第 2 版(MulanPSL-2.0)。** --- ## 目录 - [定位:它解决什么问题](#定位它解决什么问题) - [不做什么](#不做什么) - [快速开始](#快速开始) - [两条使用路径](#两条使用路径) - [浏览器运行时](#浏览器运行时) - [三层对齐](#三层对齐) - [采集层](#采集层) - [Profile 与插件](#profile-与插件) - [Evidence 与离线回放](#evidence-与离线回放) - [进程后端与资源上限](#进程后端与资源上限) - [指纹采集脚本](#指纹采集脚本) - [测试](#测试) - [命令一览](#命令一览) - [目录结构](#目录结构) - [设计原则](#设计原则) - [已知边界](#已知边界) --- ## 定位:它解决什么问题 做 Web 协议逆向时,真正的困难通常不在算法,而在**环境**。 目标站点的签名逻辑跑在浏览器里,会读 `navigator`、`screen`、`document`、 `CanvasRenderingContext2D`、`WebGLRenderingContext`,会检查 `Function.prototype.toString` 是不是 `[native code]`,会数 `arguments.length` 在参数不足时抛出的错误消息,会比对 `Object.keys(window)` 的枚举顺序。这些代码在 Node 里直接跑必然失败;在真实浏览器里跑则很难规模化、难以自动化、难以持续运行。 常见的三条路和它们的代价: | 方案 | 代价 | |---|---| | 真实浏览器 + 自动化(Puppeteer/CDP) | 资源重、易被检测、CDP 本身就是特征 | | 抠出 JS 片段用 jsdom 跑 | 环境残缺,稍微认真的检测立刻发现 | | 纯手工翻译成 Python/Go | 一次性成本极高,目标一改就重做 | NV8 走第四条:**在 Node 里把浏览器环境补到「检测不出来」的程度,然后原样运行目标 的原始脚本。** 不翻译、不改写、不注入痕迹。 补到什么程度是可以量化的,见[三层对齐](#三层对齐)。 协议恢复出来之后,采集本身还有一整套工程问题——分页、限流、熔断、代理轮换、 断点续采、去重落库。NV8 的[采集层](#采集层)把这些做完了,所以从「跑通一次签名」 到「稳定跑一个月」之间不需要另起一个项目。 --- ## 不做什么 划清边界比列举功能更重要: - **不做浏览器自动化。** 不点击、不截图、不等元素出现。需要真实渲染请用别的工具。 - **不做渲染与布局。** 没有排版引擎,所以 `offsetWidth`、`getBoundingClientRect` 这类依赖真实布局的值不可信(NV8 对这类属性返回空串而不是编造数字,见 [已知边界](#已知边界))。 - **运行时不联网。** 运行目标脚本时,所有网络访问走离线回放;回放未命中就在本地 失败,**绝不回落真实网络**。真实网络只属于采集层。 - **不内置数据库驱动。** 采集结果落地定义为接口,附内存与 NDJSON 两个实现, 接 Postgres/SQLite 由调用方提供。 - **不猜业务语义。** 分页游标怎么取、条目主键是哪个字段,都必须由调用方指定。 猜错的代价是静默少采或静默丢数据,比报错严重得多。 --- ## 快速开始 ### 环境要求 - Node.js **>= 18.18.0**(在 18/20/22/24 上均有测试;推荐 24) - `EdgeSandbox` 的隔离子进程会自动带 `--experimental-vm-modules`,宿主 runner 不必显式添加; 直接用 `createNv8`(进程内)或跑仓库测试/构建时才需要自己加 - 零依赖,`npm install` 不装任何包 **指纹敏感场景请用 Node 22+。** Node 18/20 的 V8(10.x / 11.x)在 dictionary 模式的 global object 上把**可枚举键排在不可枚举键之前**,不按插入序——违反 `[[OwnPropertyKeys]]`。后果是 `Object.getOwnPropertyNames(window)` 的顺序无法与真实 Edge 一致(实测 238 个全局排到了 V8 内建之前,`window` 落在索引 0 而真实 Edge 是 678)。而 `enumerable` 本身是要复现的契约值,不能为了顺序去改,所以这不是能 绕过去的实现问题。V8 12.x(Node 22)已修正。 `npm run capabilities` 会把这一项报成 `broken`: ``` - vm.global-property-order broken global keys are not in insertion order … ``` Node 18–22 还有一处更窄的差异:V8 内建段自身的注册顺序与 Chromium 152 不同 (TypedArray 家族的组内次序、`Iterator` 的位置)。那一段不由 NV8 安装也不由它 重排,已在 `tests/window-surface-order-test.js` 登记;Node 24 与 Chromium 逐位一致。 ### 跑一段脚本 ```js import { EdgeSandbox } from 'nv8'; const sandbox = await EdgeSandbox.create({ page: { url: 'https://example.com/list', html: '
` 在八个
locale 下一律 `monospace`——标签级 override 与 locale 无关,覆盖只作用于**基线**值。
`zh-CN` 那一项特意核查过是不是开发机产物:`Noto Sans SC` 不是上古 Windows 自带字体,
但实测它在本机 `%WINDIR%\Fonts` 里(Windows 11 的中文语言支持会装),而同目录下
`simsun.ttc` / `msyh.ttc` 都在却**没被选中**——说明这是 Chromium 对 zh-Hans 的偏好
顺序,不是「碰巧只有 Noto」。Windows 10 上大概率会落到 `Microsoft YaHei`,所以这个值
做成**可覆盖字段**而不是硬编码,与 [ADR-0005](docs/adr/0005-machine-specific-values.md)
下的 `gpu-profiles.js` 同一个套路:值是**挑选**的,不是从开发机采下来就当真理。
### 这套机制抓出来的真实问题(举例)
- **legacy 模式下完全没有原生函数伪装**:
`Function.prototype.toString.call(document.addEventListener)` 返回的是
`call(...args) { return invoke(this, args); }`。这是一击致命的特征。
- **WebIDL 参数个数检查缺失**:真实浏览器少传参数会抛带固定格式的 TypeError。
现在把检查沉到两个安装入口,用 `callback.length` 当权威来源
(已验证 **3508** 个方法的 length 与真实 Edge 完全一致),
而不是在 ~757 个调用点手写。
- **构造器错误消息缺后缀**:`Please use the 'new' operator` 少了
`, this DOM object constructor cannot be called as a function.`(38 处);
`Illegal constructor` 少了 `Failed to construct 'X': ` 前缀(245 处)。
- **`readyState` 初值错误**:内联脚本执行时是 `complete`。正常页面**永远不可能**
在 complete 状态下首次执行内联脚本——单一信号即可判定。
- **CSS 属性挂错位置**:745 个 CSS 属性在真实浏览器里是 style 对象的
**自有属性**,不在原型上。挂到原型会让第二层报 745 个多余成员。
- **URL 主机校验按两类字符实现**:`https://a b/` 被原样放过。真实浏览器把主机
字符分**三类**——safe 原样、escape 编码、forbidden 失败。空格属于 escape
(编码成 `%20`),而规范条文和 Node 都判它失败。只分两类无论选哪一侧都错。
---
## 采集层
`src/collection/collector/`。分层原则是**每一层只回答一个问题**:
```
PaginationScheduler 下一个请求是什么
Checkpoint 中断后从哪继续
↓
RateLimiter 现在能发吗
CircuitBreaker 对方还活着吗
NetworkPolicy 这个 origin 允许吗
ProxyPool 走哪个出口
↓
Transport 唯一的真实网络出口
↓
ResultSink 采到的东西放哪
```
### 分页调度(`pagination.js`)
拉取式 async iterator,`for await` 天然获得背压,随时 `break` 即停。
**三种上限,只做页数上限是不够的**:
| 上限 | 防什么 |
|---|---|
| `maxPages` | 游标永不为空 |
| **游标环检测** | 目标在绕圈 |
| `maxEmptyPages` | 到底了或出错 |
环检测最关键:只靠 `maxPages`,环形游标会在上限内**反复采同一页**——日志显示
「成功采集 500 页」,实际全是重复数据,且没有任何报错。环检测在第二次请求就停,
并报出**是哪个游标重复了**。
游标提取必须由 `nextRequest` 回调提供。`next_cursor`/`page`/`offset`/
`Link: rel=next`/嵌套字段各站点都不一样,内置猜测猜错的代价是**静默少采数据**。
### 断点续采(`checkpoint.js`)
存储是注入的(`load`/`save`/`clear` 三个方法),附内存与文件两个实现。
- **任务指纹防错续**:查询条件变了却接着旧游标走,会产出混合两次查询的数据
且不报错——这是续采最危险的 bug。指纹用 canonical JSON,`{a,b}` 与 `{b,a}`
必须同指纹。
- **保存在 yield 之后**:反过来的话,调用方处理该页时崩溃、检查点已前进,
那一页数据永久丢失。代价是续采**一定有重叠**——宁可重复交付也不能跳过。
- **已见游标一起存**:不存的话续采后环检测从零开始。
- **文件存储原子写**(临时文件 + rename):直接覆盖时进程被杀会留下截断的 JSON,
等于丢掉全部进度。
### 限流(`rate-limiter.js`)
令牌桶(`requestsPerSecond` + `burst`)与 `maxConcurrent` 是**两个独立维度**:
只限速率会让慢响应堆成无界并发;只限并发会让快响应以无界速率打出去。
选令牌桶而不是固定间隔,因为真实浏览器是「突发十几个请求然后安静」,
**完全均匀的请求流本身就是特征**。
FIFO 公平不是可选项:「谁抢到算谁的」会让高频调用方饿死早到者,
表现为「第一页永远不返回」。
### 熔断(`circuit-breaker.js`)
按 origin 的三态熔断,半开态只放一个探针。
**输入必须只有目标健康度。** 计入:超时、连接错误、5xx、429。
排除:4xx、策略违规、abort、自身的 `CIRCUIT_OPEN`、以及**所有代理故障**。
混进调用方错误会毁掉信号——把策略违规算进去,一个配置失误就能跳闸一个 origin
并掩盖真实错误。
### 代理(`proxy.js` / `proxy-transport.js`)
零依赖实现 HTTP `CONNECT` 隧道与 SOCKS5 握手(`node:net` / `node:tls`)。
**代理故障必须与目标故障分开**——这是这个模块存在的首要理由。代理不通是
我们这一侧的出口坏了。混在一起时一个代理挂掉会让熔断器跳闸所有 origin
并归咎于目标,运维看到「所有站点都挂了」,真实原因被完全掩盖。所以代理用独立
错误码 `PROXY_*`,熔断器**硬排除**(即使调用方把它配进 `tripErrors` 也不生效)。
**默认 sticky 轮换**:很多站点把会话绑定 IP。中途换出口表现为莫名掉登录态,
看起来像「协议实现错了」,会把排查带向完全错误的方向。轮换必须显式选择。
**配了代理就绝不直连**:回落直连会泄露真实出口 IP,而且完全无声——请求成功、
采集正常,等到目标把真实 IP 拉黑才发现。要允许必须显式 `allowDirect: true`。
### WebSocket 采集(`websocket-transport.js`)
WebSocket 采集是 Collector 的**有界请求/响应传输**,不是页面里的离线
`WebSocket` API,也不是长连接订阅管理器。先在 Protocol 层声明 `ws:` / `wss:`
计划和 `metadata.websocket`,再显式允许 `ws:` / `wss:` scheme:
```js
import {
createCollector,
createWebSocketTransport,
} from 'nv8/collector';
import { createRequestPlan } from 'nv8/protocol';
const plan = createRequestPlan({
method: 'GET',
url: 'wss://api.example.com/stream',
metadata: {
websocket: {
protocols: ['json'],
send: ['{"op":"ping"}'],
maxFrames: 1,
maxMessageBytes: 1024 * 1024,
},
},
});
const collector = createCollector({
transport: createWebSocketTransport(),
policy: {
enabled: true,
allowedOrigins: ['wss://api.example.com'],
allowedSchemes: ['wss:'],
},
});
```
传输负责 RFC 6455 握手、客户端掩码、文本/二进制消息、分片、Ping/Pong、关闭
和 Abort/超时清理;响应中的 `response.websocket.frames` 是有限帧集合。发送过消息
后发生的传输失败不会标记为可重试,避免重试造成业务消息重复。长连接订阅、代理
隧道和业务重连策略由调用方注入或编排,不在这个有界传输里隐式完成。
**凭据零泄露**:`password` 不可枚举,`toJSON` 只报 `authenticated: bool`,
错误消息只带脱敏 label。`JSON.stringify` / `util.inspect` / 对象展开 / 模板串
四条泄露路径各有断言。
### 结果落地(`result-sink.js`)
- **按 key 去重不按整体相等**:条目常带易变字段(`fetchedAt`、排序分数、
A/B 分桶),按整体相等去重等于不去重。
- **`keyOf` 不给就不去重**:猜不出主键,猜错会把两条不同记录当成同一条、
静默丢数据。宁可不去重也不猜。
- **NDJSON 而不是 JSON 数组**:进程被杀最多留下一个残缺末行,前面全部有效;
JSON 数组写一半就是整个文件不可解析。`readNdjsonKeys` 跳过残缺行并**计数**。
- 批量写 + `close()` 必须冲干;`persist` 抛错时先清空缓冲,否则下次 flush
会把同一批再写一遍。
### 协议层(`src/collection/request-protocol/`)
把「一次请求」表达成可序列化、可校验、可比对的 `RequestPlan`,
适配器返回**声明式变换列表**而不是直接改计划——这样变换可审计、可重放、可测试。
配套 `canonical-json.js` 提供稳定序列化与摘要(键顺序无关),
是任务指纹与去重 key 的基础。Protocol registry 和 `ProtocolResult` 使用独立的
`schemaVersion`(当前 `1.0`):同主版本的旧 minor 可消费,未来 minor 和不同 major
拒绝;它与底层 Frame Protocol 的版本不是同一个概念。
---
## Profile 与插件
**Profile** 描述「要一个什么样的浏览器」:版本、指纹字段、启用哪些插件、资源上限。
内置:`minimal`、`minimal-fetch`、`dom-replay`、`legacy-full`、
`browser-profile-edge-v150`。Edge 150/151/152 的冻结指纹从对应的
`nv8/fingerprint/edge-150`、`nv8/fingerprint/edge-151`、
`nv8/fingerprint/edge-152` 子路径读取。
```js
import { createProfile } from 'nv8';
const profile = createProfile('legacy-full');
```
**插件**是运行时装配单元,声明自己需要的能力与提供的表面。约束:
- 插件在 Sandbox 初始化期间**不得改动宿主 `globalThis`**
- 依赖按能力匹配解析,缺能力时给结构化诊断而**不修改全局**——
破坏 `typeof` 特性检测比缺诊断更糟([ADR-0002](docs/adr/0002-missing-capability-diagnostics.md))
- 可生成 **Lock Plan** 锁定装配结果,保证跨环境一致;每个插件声明独立于实现版本的
Plugin SDK `apiVersion`(当前支持 major `1`),Core 在 lock-plan 阶段拒绝未知 major
### Plugin SDK API 版本
`plugin.version` 表示插件实现版本,`apiVersion` 表示插件与 Core 之间的 SDK
契约 major,二者不能混用。插件可以省略 `apiVersion`,SDK 会填入当前值;也可以
显式声明数字 major 字符串:
```js
const plugin = definePlugin({
apiVersion: '1',
id: 'example-plugin',
version: '1.0.0',
install() {},
});
```
`apiVersion` 只接受类似 `'1'` 的数字 major 字符串,不接受 `'1.0'` 或数字类型。
未来 major 可以被插件定义以便提前开发,但 Core 会在生成 Lock Plan 时以
`PLUGIN_SDK_API_UNSUPPORTED` 拒绝尚未支持的 major;这样失败发生在安装和 Realm
创建之前,而不是插件执行到一半才失败。
Plugin Lock 可以选择使用 Ed25519 签名。`signPluginLockPlan()` 只把去掉
`signature` 字段后的 canonical Lock Plan 作为签名输入;Bundle/Lock 本身不携带
信任根,验证方必须按 `keyId` 从调用方的密钥目录提供公钥。验证端明确拒绝私钥,
签名缺失、算法未知、keyId 不匹配或内容被篡改时均 fail closed。
### Core SemVer 策略
Core 版本独立于 Plugin SDK、Evidence 和 Frame Protocol,当前版本为 `0.1.0`。
版本规则为:major 表示不兼容的公开 API 或行为契约变化,minor 表示向后兼容的
能力增加,patch 表示向后兼容的修复或对等性修正。Lock Plan 会记录 `coreVersion`
并纳入摘要,避免用插件的 `version` 误判 Core 兼容性。
内部 manifest 或宿主集成可以使用 `*`、精确版本、`^`、`~` 以及比较运算符;不满足
要求时返回 `CORE_VERSION_UNSUPPORTED`,而不是在 Realm 已启动后才失败。
### 宿主能力降级
Profile 可以声明 `requiredCapabilities` 和 `optionalCapabilities`。required 能力在
Realm 创建前检查,缺失或 `broken` 时直接抛出 `PROFILE_CAPABILITY_UNAVAILABLE`;
optional 能力默认采用 `degrade` 策略,并通过 `nv8.capabilityResolution.degradations`
返回状态、行为和原因。需要严格环境时传入 `capabilityPolicy: 'strict'`,让 optional
能力也在启动阶段失败;`ignore` 仅适用于调用方明确不需要诊断的受控场景。
```js
const nv8 = await createNv8({
profile: {
id: 'edge-target',
plugins: [],
requiredCapabilities: ['vm.context'],
optionalCapabilities: ['array-buffer.transfer'],
degradations: [{
capability: 'array-buffer.transfer',
behavior: 'use the host-compat copy fallback',
reason: 'Node 20 does not expose native transfer',
}],
},
capabilityPolicy: 'degrade',
});
```
降级不会伪造能力为可用;运行时只能使用 Profile 已声明的 fallback。缺少 required
能力或 strict 模式下的 optional 能力都会在目标脚本执行前失败。
### 受信任脚本与 CSP 边界
`runtime.scriptPolicy` 是页面脚本执行层的 CSP-like 白名单,不是对
`vm.Context` 的安全保证。它分别控制 inline、external、module 和 `data:` 脚本,
并可用 `allowedOrigins` 限制脚本来源。页面 module 的静态依赖和动态 `import()`
共用同一检查;未授权脚本收到 `ERR_NV8_SCRIPT_POLICY_REJECTED` 并派发脚本
`error` 事件,不会回退到真实网络。
```js
runtime: {
scriptPolicy: {
allowInline: false,
allowExternal: true,
allowModules: true,
allowDataUrls: false,
allowedOrigins: ['https://target.test', 'https://cdn.target.test'],
},
}
```
这条策略只约束页面目标脚本。Core 的内部 surface/module loader 不通过页面
allowlist;Evidence Bundle 中的脚本仍需另外通过 `evidence.trustedScriptPolicy`
(`entrypoints-only`、`allowlist` 或 `deny-all`)授权。这样不会把“Bundle 来源可信”
错误地等同为“Bundle 中目标脚本可信”。
指纹字段遵循 [ADR-0005](docs/adr/0005-machine-specific-values.md) 一条铁律:
**浏览器身份照抄,机器特定值保持中性。**
已经踩过三次的坑:WebGL renderer、`hardwareConcurrency`/`deviceMemory`、
CSS `fontFamily`。最后一个尤其典型——采集时不锁 locale,采集机的系统语言会
以 `fontFamily: "Noto Sans SC"` 混进默认样式表,而 `navigator.language`
声明 `en-US`,形成比缺值更糟的**内部矛盾**。
**每次扩大采集范围都必须重跑这项审计**,「上次查过了」不是有效假设。
---
## Evidence 与离线回放
Evidence Bundle 把一次真实会话固化下来:页面 HTML、脚本、网络响应、Cookie。
之后运行时从 Bundle 回放,不碰网络。
```js
const sandbox = await EdgeSandbox.create({
evidence: { path: './evidence/site.bundle.json' },
});
```
规则:
- 回放未命中 → **本地失败**,不回落真实网络
- Worker / ServiceWorker 脚本只允许来自 `data:` URL 或离线回放
- 动态 `import()` 同样走回放,允许列表作用于**解析后**的 URL
([ADR-0003](docs/adr/0003-dynamic-import.md))
这条规则让采集可复现:同一个 Bundle 在任何机器上跑出同样结果。
---
## 进程后端与资源上限
两种后端,行为必须一致:
| 后端 | 说明 |
|---|---|
| `child-process` | 默认,隔离性最好,崩溃不影响宿主 |
| `worker-thread` | 启动更快,适合高频短任务 |
```js
await EdgeSandbox.create({
execution: { backend: 'worker-thread' },
limits: {
timeoutMs: 1000, // 墙钟超时(生产安全上限)
maxHeapBytes: 512 * 1024 * 1024,
maxRealms: 16,
maxWorkerRealms: 64,
maxWorkerConnections: 128,
maxWorkerDepth: 8,
maxValueDepth: 32,
},
});
```
### 关于 `timeoutMs`
默认 **1000ms** 是**针对不可信页面脚本的生产安全上限**,不是「操作应该多快」
的断言。做重度内省(遍历全部原型成员之类)时必须显式放宽:
```js
limits: { timeoutMs: 30_000 }
```
把测试绑在这个默认值上等于在赌执行时间,和写死 sleep 是同一类错误。
### 关于 `maxHeapBytes`
这个值有两个**互不相干**的用途:算 Realm 容量守卫,和设 V8 老生代上限。
后者有硬地板——引导一个完整 Realm(337 个 install)本身就要相当的老生代空间。
实测(Node 24,各 6 次并发):
| 老生代 | 成功率 |
|---|---|
| 32MB | **0/6** |
| 48MB | **0/6** |
| 64MB | **5/6** ← 悬崖边 |
| 80MB | 6/6 |
| 96MB | 6/6 |
| 128MB | 6/6 |
给太低时 V8 在引导过程中 OOM 并 `abort()`——收到 SIGABRT,**没有任何结构化
错误**,因为 abort 之后没有 JS 能再运行。所以 NV8 把 V8 上限钳制到 128MB 下限
(`src/backend/controller/runtime-heap-floor.js`),同时**容量守卫仍按你配置的值计算**,
两者走不同路径。
---
## 指纹采集脚本
所有采集都用**无头 Edge + `--dump-dom`**,不用 Puppeteer/CDP
(CDP 本身就是特征,且引入依赖)。
| 命令 | 采集内容 |
|---|---|
| `npm run fingerprint:collect` | 身份字段(UA、brands、版本号等) |
| `npm run fingerprint:globals` | 1239 个全局名 |
| `npm run fingerprint:members` | 8957 个原型成员与描述符 |
| `npm run fingerprint:lengths` | 3508 个方法的 `length` |
| `npm run fingerprint:behavior` | 同步行为探针(211 项) |
| `npm run fingerprint:async-behavior` | Worker / ServiceWorker 异步行为探针(5 项) |
| `npm run fingerprint:css` | 746 个 CSS 属性名(保留真实枚举顺序) |
| `npm run fingerprint:ua-defaults` | 96 个标签 × 736 个属性的 UA 默认值 |
### 探针准入标准
一个行为探针要进库,必须:
1. **跨运行确定性** —— 采集脚本跑**两遍**并要求逐字节一致
2. **机器无关**
3. **可序列化**
因此刻意排除了 `measureText` 的字形宽度(依赖已装字体)和 `width`/`height`
(依赖视口与布局)。
### 探针定义必须共享
采集脚本与测试用**同一份**探针定义(`src/infra/baseline/behavior-probes.js`)。
各写一份必然漂移,而一旦漂移,比对就失去意义。
### 采集方法论上的坑
- **采集基准版本必须与 profile 一致**。用 Edge 152 的 fixture 去比 150 的
profile,会把版本门控的成员误报成缺失(这个坑踩过两次)。
- **布局相关属性的排除靠实测,不靠手写名单**。需要两轴差分:视口
(800×600 vs 1400×900)与内容(空 div vs 填充 div)。只做视口那一轴会漏掉
`height`/`blockSize`——空 div 在两种视口下都是 0px。
- **UA 默认值基线取 ``,不取众数**。众数会把 `unicodeBidi` 标错,
并把覆盖项从 82 个标签虚增到 93 个。
- **`html` 与 `body` 必须直接测页面节点**。其余标签靠「创建元素塞进 body」测量,
但 `` 不能嵌进 body。漏掉的后果很直观:
`getComputedStyle(document.body).display` 退回基线值 `inline`,
而真实浏览器是 `block`。
- **Node 的 `URL` 不能当浏览器基准**。`https://a b/` 浏览器接受并编码,
Node 直接抛。
- **跨页面/iframe 测量需要临时本地 HTTP 服务**(绑 127.0.0.1)。
`file://` 让每个文件成为不透明源,iframe 拿不到 `parent`。
- **采集脚本必须能在开发机上直接跑**。7 个 collector 原来只列了 WSL(`/mnt/c`) 与
Linux 的 Edge 路径,在原生 Windows 上必须每次手动 `--edge`。而「基准跟随本机
Edge」要成为常规做法,就不能依赖手动传参——已补上 Windows 候选路径。
- **临时目录不能硬编码 `/mnt/c/temp`**。三个用临时 HTML 页的 collector 各自写了
这个路径——而它在 Windows 上**不是标准目录**,本机就没有,`mkdtempSync` 直接
ENOENT。WSL 下 Windows 版 Edge 看不见 `/tmp`,所以确实需要一个 `/mnt/c` 下的
目录,但得探测而不是假设(`scripts/edge-temp-dir.mjs`)。这类错误的特征是
**在某个平台上从没跑过**,不是跑坏了。
- **采集页的顶层 `var` 会掺进结果**。经典脚本里顶层 `var` 会变成 globalThis 的
own property:给 `collect-edge-globals.mjs` 加 descriptor 采集时,四个临时变量把
1239 抬到了 1243。整段包在 IIFE 里。
- **形状也要采,不能猜**。Edge 152 的 window own property descriptor 中,WebIDL
表面由 1178 项顺序表管理,另有 V8/不可重排项;猜错不会报错,只会变成一处可探测
偏差——`chrome` 被写成 `configurable: false` 就是这么来的。
`edge-globals.json` 现在带 `descriptors` 字段。
### 已测出的 151 → 152 差异
本机(采集当时)Edge 已是 152,7 份 fixture 已全部重采,152 现在是对等性基准:
| 维度 | 151 | 152 | 变化 |
|---|---|---|---|
| 全局名 | 1236 | 1239 | +3:`NodeRange` `OpaqueRange` `PermissionsPolicy` |
| 原型 / 成员 | 966 / 8941 | 969 / 8957 | +6 成员,**−2**(`AbstractRange.startContainer/endContainer` 移到 `NodeRange`)|
| 方法 `length` | 3496 | 3508 | 已有方法**零变化** |
| 行为探针 | 144 | 178(当次) | **+34:字体、DOM/Range/Selection、Storage、Fetch、Crypto、XHR、WebSocket、IndexedDB** |
| CSS 属性 | 746 | 746 | 无 |
| UA 默认值 | 96 标签 | 96 标签 | 无 |
| UA / brands | `Edg/151` | `Edg/152` | brands **顺序与 GREASE 串都变了**:
`Not=A?Brand/99` → `Not?A_Brand/24`,Chromium 排到第一 |
(这张表记录**当次换基准**的对比;152 基准的探针后续继续扩充,当前同步探针为 211 项。)
两条值得单独记:
- **行为层在换基准时保持原有 144 项一致**,本轮另增 10 项稳定探针覆盖字体解析、TextMetrics 形状和 DOM/Range/Selection;扩探针不必等特定版本。
- **brands 不只是版本号变了**,GREASE 品牌串与数组顺序都变。这类字段照抄才安全,
按规律推导会错(ADR-0005 同一条铁律)。
换基准的阻塞项(`finalize-window-surface-order.js` 没有生成器、新增全局要在那份
1.5 万行文件里手改三处)**已解除**:顺序与 descriptor 形状变成了数据表,
版本门控是一个字段。现在刷新基准是三步:
```bash
npm run fingerprint:globals # 采顺序 + descriptor 形状
npm run check:surface-order # 先看差异(不一致则非零退出)
node scripts/build-window-surface-order.mjs --write
```
已用真实 Edge 152 完成重采:3 个新增全局自动带上形状,门控保留,1178 项表面
顺序与真实 152 **逐字一致**,原型成员和行为对等性测试也全部通过。
**顺序不是「旧顺序 + 追加新增项」**:实测 151 → 152 有 9 个已有全局挪了位置
(`FeaturePolicy` 523 → 69、`PerformanceLongAnimationFrameTiming` 333 → 1191,
`WebAssembly` / `XSLTProcessor` / `RTCDataChannel` / `PageRevealEvent` /
`onpagereveal` / `PerformanceScriptTiming` / `PerformanceTimingConfidence` 亦然)。
换基准必须整表重采,而这在 1.5 万行代码里等于重新生成整个文件。
`HTMLUserMediaElement`、`NodeRange`、`OpaqueRange`、`PermissionsPolicy` 和六个新增成员
已实现,并由 `tests/edge-152-surface-test.js` 锁定版本门控与运行时行为。
---
## 测试
```bash
npm test # 全量,1173 项(`node --test` 自动发现 tests/,新增测试不用注册)
npm run test:matrix # Node 18 / 20 / 22 / 24
npm run benchmark # 当前 Node / backend 的性能基准
npm run benchmark:matrix # Node 18/20/22/24 × 两种 backend 性能矩阵
npm run baseline # 重新生成基线快照
npm run audit:state # 模块级可变状态审计
npm run capabilities # 宿主能力探测报告
```
### 五条硬规矩
**1. 不许用固定时长 sleep。** 由 `tests/test-hygiene-test.js` 强制(上限 2ms)。
需要等待就用 `tests/helpers/async-wait.js` 轮询条件。
固定 sleep 的问题不是慢,是**它把「时间够了」冒充成「条件满足了」**——
在 CI 上一定会以随机的方式失败。
**2. 让位用的定时器不许 unref。** `async-wait.js` 里的 `sleep()` 曾经写了
`timer.unref()`,理由是「避免拖住进程退出」——恰好把作用弄反了:让位期间它就是
唯一该维持事件循环存活的句柄。unref 之后,只要此刻没有别的 refed 句柄,
事件循环直接排空,promise **永远不 settle**。
症状是 node:test 报
`Promise resolution is still pending but the event loop has already resolved`,
整个文件被 `cancelledByParent`。**36 项测试就这样一直没有真正运行过**,
而它们看起来只是「那几个文件红了」。用同一助手的其他文件却是绿的——
差别只在「当时恰好有没有别的活动句柄」。
比失败更糟的是这种沉默:一个断言从不执行,和它不存在没有区别,
但它在计数里、在报告里、在你以为已经覆盖了的地方。
**3. 偶发失败必须查到根因。** 不接受「资源竞争」这类结论。
真实案例:某项测试在全量套件里偶尔失败,前三次被归因为资源竞争。第四次用
**十路并发复现**(4/10 红),抓到 `SandboxChildExitError (signal=SIGABRT)`,
再打开子进程 stderr 看到 `FATAL ERROR: Reached heap limit`——
根因是 `maxHeapBytes` 被同时用于两件互不相干的事。修复后十路并发 10/10 绿。
**4. 断言「某件事没发生」不许靠等一段时间。** 那是同一个赌注换了方向。
要证明「没有多余的 `load`」,正确做法是造一个**因果哨兵**:先让被测操作完成,
再触发一次导航到哨兵 URL 并等它的 `load`。任何多余的中间事件都排在哨兵之前,
于是「有没有多余项」变成「序列是否恰好等于预期」。见
`tests/iframe-navigation-coalescing-test.js`。
等 200ms 看第三个事件有没有来是双输:一次子 Realm 构建要几百毫秒,等太短抓不到,
等太长就成了 CI 抖动源。
**5. 上界断言取多次采样的最小值,不取单次。**
冷启动预算原来只采一次样,在并行跑整套测试时它测的是「此刻机器有多忙」——
实测两次越过 3000ms 预算,而单独跑同一条只要几百毫秒。放大预算等于把噪声
正当化。取最小值是因为竞争只会让采样变大,所以最小值受污染最少。
还有个具体原因:**第一次采样包含宿主 ESM 图的加载**(约 1700 个模块,每进程
一次),那不是每次建沙箱都要付的成本。拿它去比 `benchmark` 报的 490ms,
比的是两件不同的事。
性能矩阵使用 `npm run benchmark:matrix` 采集 Node 18/20/22/24 与
`child-process` / `worker-thread` 的冷启动、热复用、Realm reset、Realm 创建销毁的
中位数、p90、最小值、最大值和 RSS 变化。
矩阵报告是描述性基线,不把机器相关的绝对毫秒数写成行为契约;跨版本比较时应
使用相同机器、相同迭代数和相同 backend。
### 测试入口不许手写路径
`package.json` 的 `test` 原来手写了 78 条测试路径。**新增测试不注册就静默不跑**
——与「修掉沉默失效的 36 项测试」同一类隐患:一个从不执行的断言,和它不存在没有
区别,但它在计数里、在报告里、在你以为已经覆盖了的地方。
现在是 `node --experimental-vm-modules --test`(不带参数,自动发现)。
为什么是无参数而不是 `--test tests/` 或 `--test 'tests/**/*-test.js'`:**两种形式
在四档之间不兼容**。Node 18/20 的位置参数只认目录,Node 22+ 只认 glob
(`--test tests/` 在 Node 24 上会去 `require('/path/tests')` 然后
`MODULE_NOT_FOUND`)。无参数模式是唯一四档通用的写法,`test-matrix.sh` 用的也是它。
代价是发现范围变成整个仓库,所以补了一条断言:**`tests/` 之外不得有匹配 Node
测试文件名模式的文件**。这条同时消掉了「测试住在产品树」——
plugin-sdk 那份测试原来在 `src/engine/core/` 下,用 `console.log` 分段、顶层断言,
既不在 `--test` 的计数里,第一项失败后面也全部不执行。
### 文档失步要靠断言,不靠 review
`sandbox_manual.md` 曾有一版在整节里介绍 `ExecutionCore` /
`createExecutionCore` / `edgeCompatPlugins` 与四个 `nv8/` 子路径——**全部不存在**;
第 17 节还描述了一套九阶段审计,用到 5 个 npm 脚本,一个都没有。这类失步已由
`tests/docs-contract-test.js` 变成机械断言,不再依赖人工 review。
这类问题读一遍就能发现,但没人会为了 review 去逐条核对 1500 行手册。所以
`tests/docs-contract-test.js` 把三类**可机械核对**的引用变成断言:
- `npm run