From 0074d09f08a4b7af2586df7692c561d12de6f567 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:01:19 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20v2.1.0=20=E6=94=B6=E5=8F=A3=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=EF=BC=88README/CHANGELOG/DEVELOPMENT/ROADMAP=20+=20?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E5=AE=88=E5=8D=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README:数字刷新为实测(52 能力 · 42 MCP · 467 单测 · 50 e2e · 23 Rust); 补 na_audit_l2 工具表项、看门狗三端接入段;删「控制台安装包只作为 CI artifact」过期行(9f95560 起已随 Release 发布)。 - CHANGELOG:[未发布] 定稿 v2.1.0 全部主题(rollback / 看门狗 / na-gui 清理 / e2e 与文档收口),与 A 线已回填段共存无重复。 - DEVELOPMENT:新增 §7.5 selfupdate 代理出网机制(NA_PROXY→环境变量→ 系统代理、PAC/socks5 明确报错、回环恒直连、计划任务环境变量告诫); §7.4 补 rollback/看门狗双通道分工表与「为什么运行期不做自动回滚」 三问引述。 - ROADMAP:新增第十节 —— v2.1.0 已落地项划掉、P2 登记(控制台自更新 v2.2.0 / Linux auditd / macOS ESF / macOS 安装器 / guard 日志轮换)。 - tests/unit/docs-numbers.test.mjs(新,6 例):README 五个数字与可 复现实测口径钉死(manifest 条目 / defs.ts 工具 / node --test 实测 / e2e 源码 / Rust 属性),「增能力不改 README」即红。单测数实测经 NODE_TEST_CONTEXT 剥离 + NA_DOCS_NUMBERS_CHILD 递归防护;注入故障 (52→53)实测 2 处守卫变红、恢复全绿。 --- CHANGELOG.md | 67 ++++++++++++++++ README.md | 18 ++++- docs/DEVELOPMENT.md | 40 ++++++++++ docs/ROADMAP.md | 36 +++++++++ tests/unit/docs-numbers.test.mjs | 127 +++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 tests/unit/docs-numbers.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 829d461..4126646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,73 @@ ## [未发布] +> 本段为 **v2.1.0** 定稿段(版本统一见 `efe4744`):selfupdate 代理出网、LKG 内容指针、 +> 三个架构审查 P0 修复、L2 三端暴露,以及 rollback / 看门狗 / na-gui 清理 / 收口。 + +### 新增 —— 控制端 `nodeagent rollback`:从 LKG 回滚被控端(ARCH T03 / PRD P0-2) + +`rollback` 补上「新版本能启动但需要回退」的通道:读 LKG → 枚举备份(`*.bak`/`*.bak-*`, +mtime 新→早)→ 分块流式 sha256 指纹匹配(`hash_full` 全量优先,老记录回落 12 位前缀) +→ 覆盖写入口文件(同 deploy 姿势:就地覆写不用 rename,Windows 真机教训)→ 写后校验 +(失败用回滚源还原)→ 独立连接重启 → 90s 墙钟复核 `build.hash == LKG`。 + +- `--dry-run` 只读零写(列出候选与匹配结果);`--backup <路径>` 逃生门:显式指定 + 回滚源、跳过指纹匹配(用于 LKG 记录丢失/全部备份被污染的极端场景)。 +- 无 LKG / 无指纹匹配:列候选后零写退出(code 2),不碰被控端一个字节。 +- 纯函数(`planRollback` / `pickBackupByHash` / `hashRemoteFile`)与 IO 分离, + 假 client 注入单测 19 例(含写后校验失败自动还原、多候选时序、Windows 路径)。 +- 能力圈边界写进 HELP 与 README:RPC 命令**需被控端可达**;「完全起不来」的场景 + 由看门狗接管(下节)。 + +### 新增 —— 被控端启动看门狗:连续失败 N 次自动从 LKG 恢复(决策 R-1) + +rollback 覆盖不了「进程完全起不来」—— 没有监听可连,RPC 无能为力。看门狗跑在 +supervisor 链上(Windows 计划任务 / Linux systemd / macOS launchd 三端已接入), +启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口文件。 + +- `scripts/startup-guard.mjs`(裸环境可跑:零项目依赖,只依赖 Node ≥22 内置模块) + + `scripts/lib/startup-guard-core.mjs`(纯函数零 import:状态机判断可单测)。 +- **30s 启动宽限期 + 端口探活**,两类误计数被消除:① agent 自身重启机制 + (kill -9 / taskkill 打死长命进程)是计划内重启;② supervisor 重启造成的端口撞车 + (新实例 EADDRINUSE 秒崩)—— 不区分的话连续 3 次即**误回滚好版本**。 +- 安全边界:**无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir**;恢复目标 + 恒为 LKG(单一、已验证,无回滚循环);恢复动作全量记 JSONL 日志。 +- 被控端启动成功后归零失败计数(`resetStartupGuardState`,`index.ts` 唯一改动点); + 守卫 spawn 时强制注入 `NODEAGENT_HOME = --data-dir`(Windows 计划任务动作不带 + 环境变量,不注入则 agent 数据目录与守卫漂移,恢复链路静默失效)。 +- Windows `RestartCount` 3 → 10:看门狗需要「连续失败 3 次 + 恢复」才生效,3 次不够用 + (良性撞车退 0 不消耗重启次数,故 10 不会造成重启风暴)。 + +### 新增 —— na-gui 陈旧计划任务白名单清理(ARCH B-P1-6) + +历史版本在被控端上注册过 `na-gui` 等旧名计划任务,漏删会留下「端口被占、 +新版本起不来」的幽灵任务。 + +- `scripts/lib/scheduled-tasks.mjs`:`TASK_WHITELIST`(`nodeagent` / `na-gui` / + `nodeagent-console`)+ `planTaskCleanup` 纯函数。**白名单外一律不删** —— + 误删用户自己的任务是比漏删严重得多的生产事故;精确匹配(大小写敏感), + 宁可漏删也不模糊匹配。 +- `scripts/uninstall.ps1`:卸载末尾按白名单逐个注销(存在性检查 + 动作进日志)。 +- `scripts/control.ps1`:新增 `cleanup` 子命令(`control.cmd` 菜单第 8 项), + 供「拿到端口占用提示后自救」。 +- 单测 10 例(白名单命中才删 / 白名单外零动作 / 空列表 / 重复名 / 脏输入)+ + ps1 源码守卫与跨端一致性守卫(白名单不许 mjs 与 ps1 两边漂移,注入故障实测会红)。 + +### 收口 —— L2 e2e 补齐 + 文档数字刷新与数字守卫(ARCH T05) + +- **e2e**:`run.mjs` 补 L2 平台门控用例(非 Windows 明确报 + `E_UNSUPPORTED_PLATFORM`,按**错误名**断言不按 code —— 协议级错误才 throw, + 能力执行失败走 `result.status=failed`);`windows.mjs` 补 status/collect/verify + 三条(字段与 manifest `returns_schema` 逐字对齐,collect 对非管理员环境 skip 容错)。 + e2e 总数 33 → 50(34 通用 + 16 Windows 专属)。 +- **README 数字刷新**:52 项能力 · 42 个 MCP 工具 · 467 项单元测试 · + 50 项端到端用例 · 23 项 Rust 壳单测;补 `nodeagent rollback` 与看门狗段、 + `na_audit_l2` 工具表项;删「控制台安装包只作为 CI artifact」过期行 + (`9f95560` 起已随 Release 发布)。 +- **数字守卫** `tests/unit/docs-numbers.test.mjs`:README 五个数字与可复现实测口径 + (manifest 条目 / defs.ts 工具 / node --test 实测 / e2e 源码 / Rust 属性)钉死, + 不一致即红 —— 「增能力不改 README」从此会红(注入故障实测验证)。 + ### 修复 —— selfupdate 在境内**必然**失败(P0-1:裸 `fetch` 不读代理) 真机症状:被控端自更新下载 `fetch failed`。根因不是网络差 —— Node 的 `fetch`(undici) diff --git a/README.md b/README.md index e571717..dcd5f81 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点 > 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。 > 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。 -**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证) +**52 项能力** · **42 个 MCP 工具** · **467 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证) > 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR) > 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。 @@ -384,8 +384,6 @@ Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架 > 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**; > 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。 -> 控制台安装包目前**只作为 CI artifact 产出**,尚未接入 `scripts/release.mjs` 随 Release 发布。 - ⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**, 非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测 `system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。 @@ -492,6 +490,18 @@ nodeagent fanout <能力名> [--nodes a,b] [--args JSON] # 多设备并发下发 > (`scripts/startup-guard.mjs`);仍不行再走带外恢复(`docs/DELIVERY-PIPELINE.md` 附录 C)。 > 自动回滚刻意不做,理由见该文 §6。 +**看门狗(被控端自启链上的一层)**:被控端由外层 supervisor 看守,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口文件: + +| 平台 | supervisor | 接入方式 | +|---|---|---| +| Windows | 计划任务 | `scripts/install.ps1` 把启动动作包一层 `scripts/startup-guard.mjs`(`RestartCount 10`) | +| Linux | systemd | `scripts/install-linux.sh` 的 unit `ExecStart` 包一层 | +| macOS | launchd | 无安装器,手工接法见 `docs/DEVELOPMENT.md` §7.4 | + +安全边界:**无 LKG / 无指纹匹配的备份时只记日志放行,绝不擦除安装目录**;恢复目标恒为 LKG +(单一、已验证,不做「择优回滚」)。看门狗自身「裸环境可跑」——零项目依赖,只依赖 +Node ≥22 内置模块 + 同目录纯函数核心,随安装包/便携包分发。 + ### 长任务与剪贴板 ```bash @@ -543,7 +553,7 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选 | `na_clip` | 读写剪贴板文本或图片 | | `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 | | `na_restart` | 受控重启被控端 | -| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 | +| `na_audit` · `na_audit_l2` · `na_discover` · `na_nodes` · `na_use` | 查审计(`na_audit_l2` = 系统级 L2,仅 Windows 被控端)、发现设备、多设备切换 | | `na_event_watch` · `na_event_poll` · `na_event_list` · `na_event_unwatch` | 事件订阅;`notify:true` 走 MCP 日志通知实时推送,否则用 `na_event_poll` 拉取 | | `na_macro_run` | 回放 GUI 宏(步骤序列,逐步校验) | | `na_metrics` | 成功指标(闭环率 / 装软件率 / P95 / 拦截率)+ 达标判定 | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 34533d7..93d2fef 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -787,6 +787,46 @@ LKG 恢复入口文件。判据与边界见 `docs/DELIVERY-PIPELINE.md` §6。 只依赖 Node ≥22 内置模块 + 同目录 `lib/startup-guard-core.mjs`(纯函数)。 分发时两个文件必须保持相对位置一起拷贝(pack.mjs / install-linux.sh 已保证)。 +### 与 `nodeagent rollback` 的分工(v2.1.0 回滚双通道) + +| 通道 | 触发者 | 覆盖场景 | 边界 | +|---|---|---|---| +| 手动 rollback | 控制端 RPC(`nodeagent rollback`) | 新版本**能启动**但需回退 | 需被控端可达(有监听可连) | +| 看门狗自动恢复 | supervisor 链上守卫 | 新版本**完全起不来**(启动连续失败 N 次) | 只恢复到 LKG;无 LKG/无匹配放行不动盘 | + +**为什么运行期仍不做自动回滚**(v2.1.0 刻意收窄,引述 `docs/DELIVERY-PIPELINE.md` §6): +① 无法归因 —— 远程设备上「更新后健康检查失败」分不清是更新本身坏了还是 +网络抖动 / 端口被占 / 依赖服务没起;② 回滚循环 —— 自动回滚可能触发 +「回滚→启动→又判定失败→再回滚」,无人值守设备上比停在坏版本更难恢复; +③ 失控 —— 被控端一旦在运行期自作主张改自己的程序,控制端失去对 +「当前跑的是什么」的掌控。故运行期的决策权留给控制端(rollback), +启动期则由看门狗兜底(目标只有一个:LKG)。 + +### 7.5 selfupdate 代理出网(v2.1.0,P0-1) + +真机症状:被控端自更新下载 `fetch failed`。根因不是网络差 —— Node 的 `fetch` +(undici)**不读** `HTTPS_PROXY` 等环境变量,而境内访问 GitHub 必经代理。 +`apps/agent/src/net/proxy.ts`(sidecar 已验证方案的 TS 移植,只依赖 node 内置模块) +接管 `system.agent.update` 的下载: + +- **代理来源优先级**:`NA_PROXY` → 目标协议环境变量(`http_proxy` / `https_proxy` / + `HTTPS_PROXY` ...,大小写都认)→ 系统代理(Windows `reg query WinINET` / + macOS `scutil --proxy`)。 +- **PAC 与 socks5 明确报 note、不静默降级**:PAC 需 JS 运行时解析、 + socks5 无内置客户端 —— 报出来让人知道「为什么没走代理」,而不是 + 悄悄直连后给一个莫名的超时。 +- **回环目标恒直连**(有意的设计偏离):代理另一侧的 localhost 不是本机 —— + CI/dev 机设了 `HTTP_PROXY` 时,发往 `127.0.0.1` 自建镜像的请求会被送进 + 远程代理而 404。 +- **重定向链共享超时预算**(防 5 跳 × 30s 穿过 RPC 超时); + **CONNECT 隧道 abort 自管**(外层 request 建立前 signal 无人受理 —— + 真机 75s 配置挂 109s 的教训);TLS 默认校验证书。 +- `maxBytes` 双闸(content-length 预检 + 流式中断),保住「64MB 上限」。 +- ⚠️ **计划任务环境变量不可见**:Windows 计划任务的启动动作**不带**用户环境变量, + 在 `install.ps1` 里设 `NA_PROXY` 对任务里的 agent 无效 —— 要么写进任务的 + `EnvironmentVariables`,要么配在 agent.json 的 `update` 段(被控端自己的配置, + 不依赖任何外部环境)。 + --- ## 第 8 章 工程结构 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9d45063..288ea31 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -445,3 +445,39 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md> 6. 发版/打包脚本不得假设 unix 命令存在(`zip`/`ls`/`du`/`node_modules/.bin/*`) 7. CI 断言会随属性变化过期,且方向可能反了 8. 工作区有他人未提交文件时禁用 `git add -A` / `commit -am` + +--- + +## 十、v2.1.0「全面完善」:已落地与遗留(2026-10-26 收口) + +本轮(`docs/ARCH-2026-10-25` / `docs/PRD-2026-10-25`)范围的全部落地情况: +计划项从路线划掉,未做的按 P2 登记在案 —— 不塞进 v2.1.0。 + +### 已落地(从计划划掉) + +| 项 | 状态 | 落点 | +|---|---|---| +| 版本统一 v2.1.0 + CHANGELOG 骨架 | ✅ | `efe4744` | +| selfupdate 代理出网(NA_PROXY→env→系统代理) | ✅ | `apps/agent/src/net/proxy.ts` | +| LKG 内容指针(`hash_full` / `backup_glob`) | ✅ | `apps/agent/src/lkg.ts` | +| 控制端 rollback(ARCH T03) | ✅ | `apps/cli/src/cmd/rollback.ts` | +| 看门狗启动自动恢复(决策 R-1) | ✅ | `scripts/startup-guard.mjs` + 三端 supervisor 接入 | +| L2 三端暴露(MCP `na_audit_l2` + 控制台 + `agent.l2`) | ✅ | `b09ca68` | +| na-gui 陈旧计划任务白名单清理 | ✅ | `scripts/lib/scheduled-tasks.mjs` + `control.ps1 cleanup` | +| L2 e2e + 文档数字守卫 | ✅ | `tests/e2e/*`、`tests/unit/docs-numbers.test.mjs` | +| 架构审查三 P0(validate 原型链 / 限速桶 key / describeArgs) | ✅ | `e864486`、`d106214` | + +### 遗留(P2 登记,明确不塞进 v2.1.0) + +| 项 | 优先级 | 为什么现在不做 | +|---|---|---| +| 控制台自更新(检查更新/自升级) | P2 · v2.2.0 | 控制台手动重装成本低;且需先定「更新通道与回滚」策略,不与本轮被控端通道混 | +| Linux L2(auditd) | P2 | 需预装 auditd + 规则管理,属被控端环境依赖,超出「零依赖立起来」原则 | +| macOS L2(ESF) | P2 | Apple entitlement 硬限,个人自签证书无法启用;除非走 Developer ID + 公证分发 | +| macOS 被控端安装器 | P2 | 当前无安装器(launchd 手工接入见 `docs/DEVELOPMENT.md` §7.4);macOS 被控端场景少,先文档兜住 | +| 看门狗日志轮换 | P2 | `startup-guard.log`(JSONL)无轮换;长期运行设备需运维关注,先以告警文案兜住 | + +### 门禁数字(2026-10-26,Mac 实测) + +52 项能力 · 42 个 MCP 工具 · 467 项单元测试 · 50 项端到端(34 通用 + 16 Windows 专属)· +23 项 Rust 壳单测 —— README 数字由 `tests/unit/docs-numbers.test.mjs` 钉死,腐烂即红。 diff --git a/tests/unit/docs-numbers.test.mjs b/tests/unit/docs-numbers.test.mjs new file mode 100644 index 0000000..9534e16 --- /dev/null +++ b/tests/unit/docs-numbers.test.mjs @@ -0,0 +1,127 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * 文档数字守卫(ARCH B-P1-3 / T05)。 + * + * ## 为什么需要 + * README 的能力数 / MCP 工具数 / 单测数 / e2e 数是「会腐烂的数字」: + * 每次加能力、加工具、加测试都可能忘同步。腐烂的数字比没有数字更糟 —— + * 它给人虚假的信心(「README 说 52 项,肯定全测过」)。 + * 本守卫把 README 的数字与**可复现的实测口径**钉在一起: + * 不一致即红,逼使「改代码必改文档」。 + * + * ## 口径(全部在 Mac/Linux 上可复现,不依赖 Windows) + * - 能力数:packages/protocol/src/capabilities/manifest/*.ts 的 `name: CapabilityNames.` 计数 + * - MCP 工具数:apps/mcp/src/tools/defs.ts 的 `name: 'na_` 计数 + * - 单元测试数:`node --test tests/unit/**\/*.test.mjs` 的汇总行实测 + * - e2e 数:tests/e2e/{run,windows}.mjs 的 `await test(` 计数之和 + * - Rust 数:apps/console/src-tauri/src/*.rs 的 `#[test]` / `#[tokio::test]` 计数 + * + * ## 递归防护 + * 单测数实测要 spawn 一个全量 node --test 子进程;该子进程也会跑到本文件。 + * 用 NA_DOCS_NUMBERS_CHILD=1 让子进程内的本用例跳过 spawn(否则无限递归), + * 子进程的汇总行仍包含本用例本身 —— README 数字即含守卫自身,自洽。 + */ + +const root = join(import.meta.dirname, '../..'); +const readme = readFileSync(join(root, 'README.md'), 'utf8'); +const isChild = !!process.env['NA_DOCS_NUMBERS_CHILD']; + +/** + * README 数字行:**52 项能力** · **42 个 MCP 工具** · **467 项单元测试** · **50 项端到端用例** · **23 项 Rust 壳单测** + * e2e 一项容许带可选中缀注释(如「(34 通用 + 16 Windows 专属)」)。 + */ +function readmeNumbers() { + const m = + /\*\*(\d+) 项能力\*\* · \*\*(\d+) 个 MCP 工具\*\* · \*\*(\d+) 项单元测试\*\* · \*\*(\d+) 项端到端用例\*\*(?:([^)]*))? · \*\*(\d+) 项 Rust 壳单测\*\*/.exec( + readme, + ); + assert.ok(m, 'README 中未找到数字行(格式应为「**N 项能力** · **N 个 MCP 工具** · ...」)'); + return { + capabilities: Number(m[1]), + mcpTools: Number(m[2]), + unitTests: Number(m[3]), + e2e: Number(m[4]), + rust: Number(m[5]), + }; +} + +function countFiles(dir, ext) { + return readdirSync(join(root, dir)) + .filter((f) => f.endsWith(ext)) + .map((f) => join(root, dir, f)); +} + +test('README 能力数与 manifest 实测一致(增能力不改 README 会红)', () => { + const manifestFiles = readdirSync(join(root, 'packages/protocol/src/capabilities/manifest')).filter((f) => + f.endsWith('.ts'), + ); + let caps = 0; + for (const f of manifestFiles) { + const src = readFileSync(join(root, 'packages/protocol/src/capabilities/manifest', f), 'utf8'); + caps += [...src.matchAll(/name: CapabilityNames\./g)].length; + } + assert.equal(readmeNumbers().capabilities, caps, `README 能力数与 manifest 不符(实测 ${caps})`); +}); + +test('README MCP 工具数与 defs.ts 实测一致', () => { + const src = readFileSync(join(root, 'apps/mcp/src/tools/defs.ts'), 'utf8'); + const n = [...src.matchAll(/name: 'na_/g)].length; + assert.equal(readmeNumbers().mcpTools, n, `README MCP 工具数与 defs.ts 不符(实测 ${n})`); +}); + +test('README MCP 工具表包含 na_audit_l2(v2.1.0 新工具必须上榜)', () => { + assert.match(readme, /na_audit_l2/, 'README 工具表缺少 na_audit_l2 —— 新增 MCP 工具必须同步文档'); +}); + +test('README 单元测试数与 node --test 实测一致', () => { + const documented = readmeNumbers().unitTests; + if (isChild) { + // 子进程(被本守卫 spawn 的全量运行)内不再递归 spawn: + // 总数由父进程从子进程汇总行读取,此处直接通过与父进程相同的值。 + assert.ok(documented > 0, 'README 单测数应为正数'); + return; + } + const files = countFiles('tests/unit', '.test.mjs'); + // ⚠️ 必须剥掉 NODE_TEST_CONTEXT:node --test 多文件模式下每个测试文件跑在 + // child-v8 上下文中,该变量会被我们的子进程继承 —— 子 node 会以为自己 + // 「已被父测试运行器管理」而拒绝执行测试(秒退、无 TAP 输出)。 + const childEnv = { ...process.env, NA_DOCS_NUMBERS_CHILD: '1' }; + for (const k of Object.keys(childEnv)) { + if (k.startsWith('NODE_TEST_')) delete childEnv[k]; + } + const out = execFileSync(process.execPath, ['--test', ...files], { + cwd: root, + env: childEnv, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }); + const matches = [...out.matchAll(/^# tests (\d+)$/gm)]; + assert.ok(matches.length > 0, '未找到 node --test 汇总行'); + const actual = Number(matches[matches.length - 1][1]); + assert.equal(documented, actual, `README 单测数与实测不符(实测 ${actual},含本守卫自身)`); +}); + +test('README 端到端用例数与 e2e 源码实测一致', () => { + let e2e = 0; + for (const f of ['tests/e2e/run.mjs', 'tests/e2e/windows.mjs']) { + const src = readFileSync(join(root, f), 'utf8'); + e2e += [...src.matchAll(/await test\(/g)].length; + } + assert.equal(readmeNumbers().e2e, e2e, `README e2e 数与 run.mjs+windows.mjs 不符(实测 ${e2e})`); +}); + +test('README Rust 壳单测数与 src-tauri 源码实测一致', () => { + const rustDir = join(root, 'apps/console/src-tauri/src'); + let n = 0; + for (const f of readdirSync(rustDir).filter((x) => x.endsWith('.rs'))) { + const src = readFileSync(join(rustDir, f), 'utf8'); + n += [...src.matchAll(/#\[(tokio::)?test\]/g)].length; + } + assert.equal(readmeNumbers().rust, n, `README Rust 数与源码实测不符(实测 ${n},CI 在 ubuntu 全量编译运行)`); +});