From dadd861fdd0ffaf75caaade35f9ea155fe652183 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:22:55 +0800 Subject: [PATCH] =?UTF-8?q?test:=20QA=20=E8=A1=A5=E5=85=85=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=88=E4=BB=A3=E7=90=86=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?/NO=5FPROXY=20=E7=BB=84=E5=90=88=E3=80=81=E7=9C=8B=E9=97=A8?= =?UTF-8?q?=E7=8B=97=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=81=A2=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E3=80=81rollback=20=E5=8F=8C=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E3=80=81README=20=E6=95=B0=E5=AD=97=E7=B2=BE?= =?UTF-8?q?=E5=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A:httpGet 重定向链经代理逐跳绝对形态+共享预算;maxRedirects 上限边界; NO_PROXY 命中直连/端口不匹配不误短路/miss+死代理大声报错(agent-proxy 已有 单跳经代理与直连重定向预算,组合形态空缺) - B:看门狗宽限期精确边界(livedMs === STARTUP_GRACE_MS 按运行期不计数); 无指纹匹配时恢复日志须带回候选指纹且 install-dir 不动 - C:pickBackupByHash 在 hash_full 在场时不许仅凭 12 位前缀命中; 编排层老 LKG(无 hash_full)端到端前缀回滚 —— 后者当前为红,是路由给 工程师的 P0 缺陷(rollback.ts:462 全量 64 位指纹与 12 位 targetHash 严格 比较,详见缺陷单),修复后自绿 - D:README e2e 拆分口径(34 通用 + 16 Windows 专属按文件分别核对); 命令速查必须含 nodeagent rollback [--dry-run] - README 单测数 467 → 476(docs-numbers 守卫强制耦合,非手滑) --- README.md | 2 +- tests/unit/qa-v210-supplement.test.mjs | 412 +++++++++++++++++++++++++ 2 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 tests/unit/qa-v210-supplement.test.mjs diff --git a/README.md b/README.md index dcd5f81..ee3be07 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点 > 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。 > 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。 -**52 项能力** · **42 个 MCP 工具** · **467 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证) +**52 项能力** · **42 个 MCP 工具** · **476 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证) > 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR) > 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。 diff --git a/tests/unit/qa-v210-supplement.test.mjs b/tests/unit/qa-v210-supplement.test.mjs new file mode 100644 index 0000000..c329f76 --- /dev/null +++ b/tests/unit/qa-v210-supplement.test.mjs @@ -0,0 +1,412 @@ +/** + * QA 独立验证补充用例(v2.1.0「全面完善」轮,严过关)。 + * + * 定位:工程师自验(第一层)已覆盖主轴后,本文件补**组合/边界/精度**三类薄面, + * 全部「能挂掉错误实现、不 tautological」: + * + * A. 出站代理(apps/agent/src/net/proxy.ts): + * ① 重定向链**经代理**时每跳仍走代理(绝对形态)且整链共享一个超时预算 + * —— 既有守卫只覆盖「直连重定向共享预算」与「单跳经代理」,组合形态空缺; + * ② maxRedirects 上限边界:超限返回最后一个重定向响应本身(不跟死、不抛错); + * ③ NO_PROXY 组合:命中 → 直连;端口不匹配 → 不得误短路;miss + 死代理 → + * 必须大声报错(静默直连正是本轮修复的缺陷本体)。 + * + * B. 看门狗(scripts/lib/startup-guard-core.mjs + scripts/startup-guard.mjs): + * ④ 宽限期**精确边界** livedMs === STARTUP_GRACE_MS 按运行期处理(>= 的 off-by-one); + * ⑤ 无指纹匹配时 restoreFromLkg 必须带回各候选指纹(恢复日志的排查依据), + * 且日志 JSONL 如实承载 reason/candidates,install-dir 一个字节不动。 + * + * C. rollback(apps/cli/src/cmd/rollback.ts)两条指纹匹配路径: + * ⑥ LKG 持 hash_full(64 位)时,仅前 12 位相同**不得**算命中(全量精度优先); + * ⑦ 老 LKG 只有 12 位 hash → 编排层端到端按前缀命中并完成回滚(向后兼容路径)。 + * + * D. 文档数字(README ↔ 实测)精度补强: + * ⑧ e2e 拆分口径:「34 通用 + 16 Windows 专属」与两个文件分别实测一致 + * (既有 docs-numbers 只校验总和,挪用例不换总数的漂移漏网); + * ⑨ README 命令速查必须包含 `nodeagent rollback [--dry-run]` + * (v2.1.0 新 CLI 命令的文档同步,与 na_audit_l2 工具表守卫同款)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import http from 'node:http'; +import { httpGet, resolveProxy } from '../../apps/agent/dist/net/proxy.js'; +import { STARTUP_GRACE_MS, decideAfterExit, initialState } from '../../scripts/lib/startup-guard-core.mjs'; +import { appendGuardLog, restoreFromLkg } from '../../scripts/startup-guard.mjs'; +import { pickBackupByHash, runRollback } from '../../apps/cli/dist/cmd/rollback.js'; + +const root = join(import.meta.dirname, '../..'); +const readme = readFileSync(join(root, 'README.md'), 'utf8'); + +// =========================================================================== +// A. 出站代理:重定向预算与 NO_PROXY 组合 +// =========================================================================== + +/** 起一个最小 HTTP 源站,返回 {srv, url}(url(p) 拼路径)。 */ +async function startOrigin(handler) { + const srv = http.createServer(handler); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + return { srv, url: (p) => `http://127.0.0.1:${port}${p}` }; +} + +/** 起一个「真转发」的 HTTP 代理;记录它看到的请求行(判定绝对形态/逐跳经过)。 */ +async function startHttpProxy() { + const seen = []; + const srv = http.createServer((req, res) => { + seen.push(req.url ?? ''); + let u; + try { + u = new URL(req.url ?? ''); + } catch { + res.writeHead(400); + res.end('not absolute-form'); + return; + } + const up = http.request( + { + host: u.hostname, + port: u.port, + path: `${u.pathname}${u.search}`, + method: req.method, + headers: { ...req.headers, host: u.host }, + }, + (r) => { + res.writeHead(r.statusCode, r.headers); + r.pipe(res); + }, + ); + up.on('error', () => { + res.writeHead(502); + res.end('upstream error'); + }); + req.pipe(up); + }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + return { srv, seen, url: `http://127.0.0.1:${srv.address().port}` }; +} + +test('A① 重定向链经代理:每一跳都走代理(绝对形态)且整链共享一个超时预算', async () => { + // /a → 302 /b → 302 /hang(挂起)。组合守卫盯两个回归: + // - 跟随后「忘了还走代理」:第二跳退化成直连(代理只见一条请求行); + // - 每跳各自计时:总耗 = 3×timeout,会穿过上层 RPC 超时。 + const origin = await startOrigin((req, res) => { + if (req.url === '/a' || req.url === '/b') { + res.writeHead(302, { location: req.url === '/a' ? '/b' : '/hang' }); + res.end(); + return; + } + // /hang:什么都不回 + }); + const proxy = await startHttpProxy(); + const timeoutMs = 600; + const t0 = Date.now(); + try { + await assert.rejects(httpGet(origin.url('/a'), { proxy: new URL(proxy.url), timeoutMs }), /abort|中止|超时/i); + const dt = Date.now() - t0; + assert.ok( + dt < timeoutMs * 2, + `经代理的重定向链也应共享预算(总耗 ≈ ${timeoutMs}ms),实际 ${dt}ms`, + ); + // 两个 302 跳都必须以绝对形态经过代理(证明跟随重定向没有偷偷直连) + assert.ok( + proxy.seen.some((u) => u.endsWith('/a')) && proxy.seen.some((u) => u.endsWith('/b')), + `两跳都应经代理(绝对形态),实际看到: ${JSON.stringify(proxy.seen)}`, + ); + assert.ok( + proxy.seen.every((u) => u.startsWith('http://')), + `代理收到的请求行必须是绝对形态,实际: ${JSON.stringify(proxy.seen)}`, + ); + } finally { + origin.srv.closeAllConnections(); + origin.srv.close(); + proxy.srv.closeAllConnections(); + proxy.srv.close(); + } +}); + +test('A② httpGet:超过 maxRedirects 上限 → 返回最后一个重定向响应本身(不跟死、不抛错)', async () => { + // 无限 302 链:/r0 → /r1 → /r2 …。跟到上限必须停下并把最后一个 3xx 交还给调用方 + // (selfupdate 会按 HTTP 状态报错),而不是死循环打满 CPU 或把 3xx 当成功。 + const origin = await startOrigin((req, res) => { + const n = Number((req.url ?? '/r0').slice(2)); + res.writeHead(302, { location: `/r${n + 1}` }); + res.end(); + }); + try { + const capped = await httpGet(origin.url('/r0'), { timeoutMs: 5000, maxRedirects: 2 }); + assert.equal(capped.status, 302, '超限后应返回最后一个重定向响应,而不是继续跟'); + assert.equal(capped.url, origin.url('/r2'), 'url 应报出停留的那一跳'); + + // 对照组:跳数未超限时照常跟随到 200(防「修过头」把正常重定向也掐了) + const origin2 = await startOrigin((req, res) => { + if (req.url === '/x') { + res.writeHead(302, { location: '/y' }); + res.end(); + return; + } + res.writeHead(200); + res.end('OK'); + }); + try { + const ok = await httpGet(origin2.url('/x'), { timeoutMs: 5000, maxRedirects: 2 }); + assert.equal(ok.status, 200); + assert.equal(ok.body.toString(), 'OK'); + } finally { + origin2.srv.close(); + } + } finally { + origin.srv.close(); + } +}); + +test('A③ NO_PROXY 组合:命中直连 / 端口不匹配不误短路 / miss+死代理大声报错', async () => { + // 目标用 https:ENV_ORDER['https:'] 才含 HTTPS_PROXY(http 目标按协议只读 + // HTTP_PROXY 系,这是 pickEnvProxy 的既定优先级,别在用例里搞错协议)。 + const dl = 'https://dl.example.com/agent.mjs'; + const deadProxy = 'http://127.0.0.1:1'; + + // ⑴ 精确命中 → 直连(proxy=null)且 note 说明原因 + const hit = await resolveProxy({ targetUrl: dl, env: { HTTPS_PROXY: deadProxy, NO_PROXY: 'dl.example.com' } }); + assert.equal(hit.proxy, null, 'NO_PROXY 命中必须直连,不被 env 代理抢走'); + assert.match(hit.notes.join(';'), /NO_PROXY/, '命中要有 note(可诊断)'); + + // ⑵ 端口不匹配 → 不得误短路(NO_PROXY 写了 host:9433,目标走 443,代理仍生效) + const portMiss = await resolveProxy({ + targetUrl: dl, + env: { HTTPS_PROXY: deadProxy, NO_PROXY: 'dl.example.com:9443' }, + }); + assert.equal(portMiss.proxy?.href, `${deadProxy}/`, 'NO_PROXY 端口不匹配时不得误判为直连'); + + // ⑶ miss + 代理是死端口 → httpGet 必须报错(静默直连正是本轮修复的缺陷本体) + const miss = await resolveProxy({ targetUrl: dl, env: { HTTPS_PROXY: deadProxy } }); + assert.equal(miss.proxy?.href, `${deadProxy}/`, '未配 NO_PROXY 时应选到 env 代理'); + const origin = await startOrigin((_req, res) => { + res.writeHead(200); + res.end('SHOULD-NOT-BE-REACHED'); + }); + try { + await assert.rejects( + httpGet(origin.url('/agent.mjs'), { proxy: miss.proxy, timeoutMs: 3000 }), + /ECONNREFUSED|代理/, + '代理不可达时必须大声失败;若实现「代理失败就自动直连」这里会拿到 200', + ); + // ⑷ 组合闭环:决议为直连(proxy=null)时同一源站必须拉得到 —— NO_PROXY 命中后 + // 的「直连」是真的能连,而不是把请求扔进黑洞。 + const direct = await httpGet(origin.url('/agent.mjs'), { proxy: hit.proxy, timeoutMs: 3000 }); + assert.equal(direct.status, 200); + } finally { + origin.srv.close(); + } +}); + +// =========================================================================== +// B. 看门狗:宽限期边界与恢复日志内容 +// =========================================================================== + +test('B④ decideAfterExit:宽限期精确边界 livedMs === STARTUP_GRACE_MS 按运行期处理(不计数)', () => { + // >= 与 > 的 off-by-one:恰好活满宽限期的死亡是运行期事件(计划内重启杀的 + // 正是长命进程),一旦把它计成启动失败,N 次计划内重启后就会误触发回滚, + // 把好版本回滚掉 —— 比坏版本停摆更糟。 + const at = decideAfterExit({ + code: null, signal: 'SIGKILL', livedMs: STARTUP_GRACE_MS, stopping: false, serviceUp: false, + state: { failures: 2, updated_at: '' }, nowIso: 'T', + }); + assert.equal(at.counted, false, '恰好活满宽限期不得计入启动失败'); + assert.equal(at.reason, 'runtime-exit'); + assert.equal(at.persist, false, '状态没变不写盘'); + + const below = decideAfterExit({ + code: 1, signal: null, livedMs: STARTUP_GRACE_MS - 1, stopping: false, serviceUp: false, + state: { failures: 2, updated_at: '' }, nowIso: 'T', + }); + assert.equal(below.counted, true, '差 1ms 没活过宽限期 → 启动失败,计数 +1'); + assert.equal(below.state.failures, 3); +}); + +test('B⑤ restoreFromLkg:无指纹匹配 → 带回候选指纹(恢复日志排查依据)且 install-dir 不动', () => { + const box = mkdtempSync(join(tmpdir(), 'na-guard-nomatch-')); + try { + const { dataDir, lkgDir, installDir } = { dataDir: box, lkgDir: box, installDir: box }; + // LKG 指向一份内容,磁盘上两个备份都不是它 + const want = Buffer.from('LKG-GOOD-CONTENT'); + const wantFull = createHash('sha256').update(want).digest('hex'); + writeFileSync(join(dataDir, 'agent-lkg.json'), JSON.stringify({ + hash: wantFull.slice(0, 12), hash_full: wantFull, bytes: want.length, + script_path: join(installDir, 'agent.mjs'), recorded_at: 1, node_version: 'v22', platform: 'linux', + })); + writeFileSync(join(lkgDir, 'agent.mjs.bak'), Buffer.from('UNRELATED-1')); + writeFileSync(join(lkgDir, 'agent.mjs.bak-1729300000000'), Buffer.from('UNRELATED-2')); + const entryBefore = Buffer.from('BAD-ENTRY'); + writeFileSync(join(installDir, 'agent.mjs'), entryBefore); + + const r = restoreFromLkg({ dataDir, lkgDir, installDir, entry: 'agent.mjs' }); + assert.equal(r.restored, false, '无匹配不得恢复'); + assert.equal(r.reason, 'no-match'); + // 恢复日志的排查依据:每个候选都要带「路径 + 12 位指纹」(main 会原样写进 JSONL) + assert.equal(r.candidates.length, 2, '候选指纹清单必须完整带回'); + for (const line of r.candidates) { + assert.match(line, /^\S+ [0-9a-f]{12}$/, `候选清单行应形如「路径 12位指纹」: ${line}`); + } + assert.ok( + readFileSync(join(installDir, 'agent.mjs')).equals(entryBefore), + '无匹配时 install-dir 一个字节都不许动', + ); + + // ⑵ 日志内容:按 main() 的口径落一条 restore 日志,JSONL 必须如实承载 + // reason 与 candidates —— 没有它们,运维拿到「未恢复」三个字无从排查。 + appendGuardLog(dataDir, { + action: 'restore', failures: 3, threshold: 3, + restored: r.restored, backup: r.backup, entry: r.entry, reason: r.reason, candidates: r.candidates, + }); + const logPath = join(dataDir, 'logs', 'startup-guard.log'); + assert.ok(existsSync(logPath), '恢复尝试必须留日志'); + const rec = JSON.parse(readFileSync(logPath, 'utf8').trim().split('\n').pop()); + assert.equal(rec.action, 'restore'); + assert.equal(rec.reason, 'no-match'); + assert.equal(rec.restored, false); + assert.equal(rec.failures, 3); + assert.equal(rec.threshold, 3); + assert.equal(rec.candidates.length, 2); + assert.ok(rec.ts, '日志必须带时间戳'); + } finally { + rmSync(box, { recursive: true, force: true }); + } +}); + +// =========================================================================== +// C. rollback:hash_full 与 12 位前缀两条匹配路径 +// =========================================================================== + +test('C⑥ pickBackupByHash:LKG 持 hash_full(64 位)时,仅前 12 位相同不得算命中', () => { + // 全量指纹在手就不允许用前缀糊弄:否则「另一个只是前 12 位碰巧相同的备份」 + // 会被当成 LKG 写进入口 —— 回滚恢复成一个从来没上线过的版本。 + const wantFull = 'a'.repeat(64); + const prefixOnly = 'a'.repeat(12) + 'b'.repeat(52); // 前 12 位相同、全量不同 + assert.equal( + pickBackupByHash([{ path: '/x/agent.mjs.bak', hash: prefixOnly, mtime: 1 }], wantFull), + null, + 'hash_full 在场时必须全量相等,前缀相同不算命中', + ); + // 对照组:全量相等 → 命中;老记录 12 位 → 前缀命中(两条路各自有效) + assert.equal( + pickBackupByHash([{ path: '/x/agent.mjs.bak', hash: wantFull, mtime: 1 }], wantFull)?.path, + '/x/agent.mjs.bak', + ); + assert.equal( + pickBackupByHash([{ path: '/x/agent.mjs.bak', hash: wantFull, mtime: 1 }], wantFull.slice(0, 12))?.path, + '/x/agent.mjs.bak', + '老 LKG 只有 12 位时按前缀命中(向后兼容)', + ); +}); + +test('C⑦ 编排层:老 LKG 只有 12 位 hash(无 hash_full)→ 按前缀命中并完成回滚', async () => { + // 端到端走「向后兼容」那条路:planRollback 回落 12 位 → hashRemoteFile 流式指纹 + // → pickBackupByHash 前缀命中 → 覆盖写入 → 写后校验。单测层覆盖过纯函数, + // 这里钉死编排层接线(lkg.hash_full ?? lkg.hash)在老记录上确实走得通。 + const LKG_BYTES = Buffer.from('LKG-AGENT-CONTENT-v2.0.3'); + const CUR_BYTES = Buffer.from('CURRENT-AGENT-CONTENT-v2.1.0-broken'); + const lkgFull = createHash('sha256').update(LKG_BYTES).digest('hex'); + const lkg12 = lkgFull.slice(0, 12); + + const files = new Map([ + ['__info__', { + agent_script: '/opt/nodeagent/agent.mjs', + build: { hash: createHash('sha256').update(CUR_BYTES).digest('hex').slice(0, 12) }, + lkg: { + lkg: { + hash: lkg12, // 老记录:没有 hash_full + bytes: LKG_BYTES.length, + script_path: '/opt/nodeagent/agent.mjs', + recorded_at: 1_700_000_000_000, + node_version: 'v22.20.0', + platform: 'linux', + }, + current_hash: 'x', + matches: false, + byte_delta: 0, + }, + }], + ['/opt/nodeagent/agent.mjs', CUR_BYTES], + ['/opt/nodeagent/agent.mjs.bak', LKG_BYTES], + ]); + const calls = []; + const fake = { + async invoke(cap, args) { + calls.push({ cap, args }); + switch (cap) { + case 'system.info': + return { status: 'ok', data: files.get('__info__') }; + case 'fs.list': + return { + status: 'ok', + data: { + entries: [...files.entries()] + .filter(([p]) => !p.startsWith('__')) + .map(([p, buf]) => ({ name: p.split(/[\\/]/).pop(), path: p, type: 'file', size: buf.length, mtime: 100 })), + }, + }; + case 'fs.read': { + const buf = files.get(args.path); + const off = args.offset ?? 0; + const chunk = buf.subarray(off, off + (args.max_bytes ?? 512 * 1024)); + return { + status: 'ok', + data: { data: chunk.toString('base64'), bytes: chunk.length, eof: off + chunk.length >= buf.length }, + }; + } + case 'fs.write': { + const incoming = Buffer.from(args.data ?? '', 'base64'); + const cur = args.append ? (files.get(args.path) ?? Buffer.alloc(0)) : Buffer.alloc(0); + files.set(args.path, Buffer.concat([cur, incoming])); + return { status: 'ok', data: { bytes: incoming.length } }; + } + default: + return { status: 'failed', error: { name: 'E_CAPABILITY_NOT_FOUND', message: cap } }; + } + }, + }; + + const r = await runRollback(fake, { json: true }); + assert.equal(r.code, 0, '老 LKG 前缀路径应能完成回滚'); + assert.equal(r.plan.action, 'match'); + assert.equal(r.plan.targetHash, lkg12, 'targetHash 应回落 12 位(hash_full ?? hash)'); + assert.equal(r.source.path, '/opt/nodeagent/agent.mjs.bak'); + assert.equal(r.needsRestart, true); + assert.deepEqual(files.get('/opt/nodeagent/agent.mjs'), LKG_BYTES, '入口内容应被替换为 LKG 内容'); + // 只走了读 + 写入口,没有多余调用(如 restart 由 cmdRollback 负责,runRollback 不碰) + assert.ok(calls.every((c) => ['system.info', 'fs.list', 'fs.read', 'fs.write'].includes(c.cap))); +}); + +// =========================================================================== +// D. 文档数字:e2e 拆分口径与新命令上榜 +// =========================================================================== + +test('D⑧ README e2e 拆分口径:「N 通用 + M Windows 专属」与两个文件分别实测一致', () => { + // 既有 docs-numbers 只校验总和(50):把用例从 run.mjs 挪进 windows.mjs、 + // 总数不变的漂移会漏网。这里按文件分别核对(括号段缺省时退化为只信总和, + // 与 docs-numbers 的可选后缀口径一致,不新增脆弱性)。 + const m = /\*\*(\d+) 项端到端用例\*\*((\d+) 通用 \+ (\d+) Windows 专属)/.exec(readme); + if (!m) return; // 文档未写拆分:总和守卫已覆盖,不强制 + const runSrc = readFileSync(join(root, 'tests/e2e/run.mjs'), 'utf8'); + const winSrc = readFileSync(join(root, 'tests/e2e/windows.mjs'), 'utf8'); + const runCount = [...runSrc.matchAll(/await test\(/g)].length; + const winCount = [...winSrc.matchAll(/await test\(/g)].length; + assert.equal(Number(m[2]), runCount, `README「${m[2]} 通用」与 run.mjs 实测不符(实测 ${runCount})`); + assert.equal(Number(m[3]), winCount, `README「${m[3]} Windows 专属」与 windows.mjs 实测不符(实测 ${winCount})`); + assert.equal(Number(m[1]), runCount + winCount, '拆分之和应等于总数'); +}); + +test('D⑨ README 命令速查必须包含 nodeagent rollback [--dry-run](新命令文档同步)', () => { + // 与 docs-numbers 的 na_audit_l2 守卫同款思路:v2.1.0 新增的 CLI 命令必须上榜。 + // 改了命令名/签名而不同步 README → 红。(help.ts ⇄ parseArgs 的 L-01 总闸管 + // CLI 侧一致,本守卫管文档侧一致。) + assert.match( + readme, + /nodeagent rollback \[--dry-run\]/, + 'README 命令速查缺少 nodeagent rollback [--dry-run] —— 新 CLI 命令必须同步文档', + ); +});