#!/usr/bin/env node /** * 跑套件,并从**同一次运行的 TAP 输出**里数结果行 —— 任何用例名出现两次就红。 * * # 为什么需要它:这是一条**因果无关**的判据 * * `test/env-guard.test.mjs` 里那条"测试文件之间不许互相 import"只能发现**已知的 * 那个成因**。同族的另一种成因它看不见,比如**两个文件里恰好同名**的用例 —— * `node --test` 按文件注册,跨文件重名**不会被拦**。2026-09-14 实测确认: * * 两个文件各写一个 `test('同名用例')` ⇒ `# tests 2 / # pass 2 / # fail 0` * (两条 `ok`,没有任何警告) * * 本脚本不挑成因:**只数结果行**。它同时也能抓住"二次注册"那类问题, * 所以和那条静态判据是**互补**的(静态那条更早、能指认是哪个文件引了哪个; * 这条更晚、但因果无关)。 * * # 判据锚在哪一行(这条是被人纠正出来的) * * 锚 `^(ok|not ok) - ` —— **结果行**。pi 先前给的一条验证命令是 * `grep -c '<用例名>'`,那个数的是**字符串出现次数**:TAP 里同一个用例名既出现在 * `# Subtest:` 头、又出现在结果行 ⇒ 2 倍效应 + 2 倍噪声,**两个因子恰好同值**, * 于是"4"看起来还能解释。若行种类是 3,就会把"两次注册"读成"三次"。 * 规矩:**判据必须锚在与结论一一对应的那一行上**,而不是"出现过"。 * * # 退出码(与 `env-preflight.mjs`、`redeploy-plugin.sh` 同一套) * * 0 = 套件全绿 **且**没有重名 * 1 = 断言失败(套件红了)**或**发现重名(这是真问题,要看代码) * 2 = 环境/参数问题(临时目录不足等) * * `--self-check`:不跑套件,只用两段合成的 TAP 样本验本脚本自己 * (干净样本必须放行、重复样本必须点名)—— 这就是"判据自检",与 * `check-deploy-drift.mjs --self-check` 同一形状。 */ import { spawnSync } from 'node:child_process'; import process from 'node:process'; /** TAP 结果行:`ok 12 - 名字` / `not ok 12 - 名字`(名字里可能有 `#` 之类,照收)。 */ const RESULT_RE = /^(not ok|ok) (\d+) - (.*)$/; /** 只认"用例"行:跳过整份文件的汇总行(形如 `ok 1 - test/xxx.test.mjs` 也算结果行, * 所以不能只靠这一条区分)。这里用"出现次数"来做判断,不区分文件级与用例级 —— */ export function findDuplicates(tapText) { const counts = new Map(); for (const line of tapText.split('\n')) { const m = RESULT_RE.exec(line); if (!m) continue; const name = m[3].trim(); counts.set(name, (counts.get(name) || 0) + 1); } return [...counts.entries()] .filter(([, n]) => n > 1) .map(([name, n]) => ({ name, count: n })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); } /** 从 TAP 里取汇总:`# tests N` / `# pass N` / `# fail N`。取不到就是没跑完。 */ export function summaryOf(tapText) { const grab = (k) => { const m = new RegExp(`^# ${k} (\\d+)$`, 'm').exec(tapText); return m ? Number(m[1]) : null; }; return { tests: grab('tests'), pass: grab('pass'), fail: grab('fail') }; } const SELF_CHECK_SAMPLES = [ { what: '干净样本:每个名字只出现一次 → 必须放行', tap: [ '# Subtest: 甲', 'ok 1 - 甲', '# Subtest: 乙', 'ok 2 - 乙', '1..2', '# tests 2', '# pass 2', '# fail 0', ].join('\n'), expect: [], }, { what: '★重复样本:同名两次(正是跨文件重名的形状)→ 必须点名', tap: [ '# Subtest: 同名用例', 'ok 1 - 同名用例', '# Subtest: 同名用例', 'ok 2 - 同名用例', '1..2', '# tests 2', '# pass 2', '# fail 0', ].join('\n'), expect: ['同名用例'], }, { what: '★噪声样本:名字只出现在 `# Subtest:` 头里(没有结果行)→ 不算重复', tap: ['# Subtest: 只在头里', '1..0', '# tests 0', '# pass 0', '# fail 0'].join('\n'), expect: [], }, { what: '★名字里带 `#` 也要能取对(别把名字截断成前缀)', tap: ['ok 1 - ★0 字节不是「不知道」', 'ok 2 - ★0 字节不是「不知道」', '1..2'].join('\n'), expect: ['★0 字节不是「不知道」'], }, ]; /** * 判据自检:拿合成 TAP 样本验 `findDuplicates` 的**分辨力**。 * * ★★ 返回失败条数,**不自己 `process.exit`** —— 这样它能在默认路径里被调用。 * 为什么必须能(pi 2026-09-18 实测报的洞): * · `package.json` 的 `npm test` = `env-preflight && run-suite`,**不带 `--self-check`**; * `deploy/install.sh` 也是 `npm test`,同样不带。全仓 `--self-check` 零引用, * 且本插件下**没有任何 .md** 提到它。 * · ⇒ 它此前**一次都没在真实路径上跑过**。实测:把 `findDuplicates` 改成恒返回 `[]` * ⇒ **`npm test` 照样 `exit=0`**(而带 `--self-check` 才红)。 * · 后果不是小事:`findDuplicates` 是**跨文件重名**那条判据的引擎, * 而它**唯一**的分辨力判据就是这个自检 ⇒ 它哪天退化成恒空, * 重名判据会**安静地永远放行**,而整套测试全绿。 * ⚠️ 判"自检在不在路径上"**不能靠 `grep` 数命中**:本仓库实测 `grep -c '判据自检'` * 在默认跑里得到 **16 次**,全是若干 `.test.mjs` 的**用例名**恰好含这四个字, * 而 `selfCheck()` 自己的输出(`^ (通过|失败) `、`干净样本`…)**一次都没有**。 * **数命中 = 数到的是词,不是调用。** */ function selfCheck() { let bad = 0; for (const s of SELF_CHECK_SAMPLES) { const got = findDuplicates(s.tap).map((d) => d.name); const ok = JSON.stringify(got) === JSON.stringify(s.expect); console.log(` ${ok ? '通过' : '失败'} ${s.what}`); if (!ok) { bad += 1; console.log(` 期望 ${JSON.stringify(s.expect)},实际 ${JSON.stringify(got)}`); } } console.log(bad === 0 ? '\n判据自检:全部通过。' : `\n判据自检:${bad} 条失败。`); return bad; } // `--self-check`:只跑自检、不跑套件(原有行为不变)。 if (process.argv.includes('--self-check')) process.exit(selfCheck() === 0 ? 0 : 1); // ── 跑套件 ──────────────────────────────────────────────────────── /* * ★★ **默认路径也要先跑自检**(pi 2026-09-18)。 * * 顺序要紧:**自检排在跑套件之前**。理由是量纲 —— 自检判的是"引擎的分辨力", * 套件判的是"代码对不对"。引擎坏了的时候,套件那份结论**本来就不可信**, * 拿它当主结论是把两件事混在一起(`docs/DEV-TOOLING.md:218` 那条 = 退出码也有量纲)。 * 而先跑自检还省一整轮测试进程(引擎坏了就没必要跑 509 条)。 * * 退出码用本文件已经约定的那一套,并**与套件红分开**: * 0 = 套件全绿且无重名(且自检通过) * 1 = 断言失败 / 发现重名(代码问题) * 2 = 环境(env-preflight 那套)—— 自检失败**不走这一格** * ★ 自检失败 = **判据本身坏了**,这是"工具问题"而不是"被测代码问题", * 所以它**不复用 `1`**(那会让 `install.sh` 把"引擎坏了"读成"代码有问题")。 * 这里用 **3** 表达"这次结论不可信(工具坏了,不是代码坏了)", * 并在 stderr 里**明说**是哪个量纲 —— 量纲说不清就退化成"又一个红"。 * (`install.sh` 那侧的退出码语义**本次不动**:它只判"插件装好没有", * `set -e` 下非零即中止,3 与 1 对它的行为一致;改它的语义是另一件事, * 且要先想清楚 `install.sh` 该不该为此新增一档。) */ const selfCheckBad = selfCheck(); if (selfCheckBad > 0) { console.error(`[tap-dup] 判据自检 ${selfCheckBad} 条失败 —— \`findDuplicates\` 的分辨力坏了,` + '**本次重名判据的结论不可信**(工具坏了,不是被测代码坏了)。'); process.exit(3); } // `--test-reporter=tap` 让报告格式稳定(`--test` 在 TTY 下会自动换格式, // 那样就锚不住结果行)。输出走管道,本进程负责原样转写到 stderr。 const args = ['--test', '--test-reporter=tap', 'test/*.test.mjs']; const r = spawnSync(process.execPath, args, { encoding: 'utf8', env: process.env, maxBuffer: 256 * 1024 * 1024, }); if (r.error) { console.error(`[tap-dup] 起不了测试进程:${r.error.message}`); process.exit(2); } const tap = r.stdout || ''; process.stderr.write(tap); // 环境不足(前置脚本那套约定的 2)原样透传 —— 这一层不替环境问题下结论。 if (r.status === 2) { console.error('[tap-dup] 套件以 2 退出(环境问题),不据此判重名。'); process.exit(2); } const s = summaryOf(tap); if (s.tests === null) { console.error('[tap-dup] 拿不到 `# tests` 汇总 —— 套件没跑完(可能崩了)。'); process.exit(1); } const dups = findDuplicates(tap); const failed = (s.fail ?? 0) > 0 || r.status !== 0; if (dups.length > 0) { console.error('\n[tap-dup] 有用例名在同一次运行里出现多次:'); for (const d of dups) console.error(` ${d.count}× ${d.name}`); console.error(' 跨文件重名不会被 runner 拦下(按文件注册)⇒ 这多半是两个文件里有同名用例,'); console.error(' 也可能是某个测试文件被二次注册(例如别的测试文件 import 了它)。'); } console.log(`\n# 结果行重复检查:${s.tests} 条结果,${dups.length} 个名字重复`); if (failed) { console.error(`[tap-dup] 套件红:pass ${s.pass} / fail ${s.fail}`); process.exit(1); } if (dups.length > 0) process.exit(1); console.log('[tap-dup] 套件全绿,且没有重名。');