Files
nodeagent/tests/unit/docs-numbers.test.mjs
T
jkooe 0074d09f08 docs: v2.1.0 收口回填(README/CHANGELOG/DEVELOPMENT/ROADMAP + 数字守卫)
- 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 处守卫变红、恢复全绿。
2026-10-26 01:01:19 +08:00

128 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 全量编译运行)`);
});