pi 复核后指出:`lib/` 会被 `cp -a "$SRC/." "$STAGING/"` **整份打进生产快照**
(排除清单只有 `test/`、`.git`、`node_modules/.cache`),而我们那三个测试侧模块
(`tmp-space.mjs`、`env-error.mjs`、`session-fixtures.mjs`)都住在 `lib/` 里。
后果不是几 KB,而是"漂移 N 处"这个数字**虚高**、哈希清单变长 ——
而"手抄哈希清单"正是我们刚定性为会过期的东西。
## 规则写成**可判定的**,不写成约定
lib/ = 从生产入口可达的模块(会进快照)
test/lib/ = 只被测试引用的模块(test/ 不部署、也不被注册进套件)
`test/lib/reach.mjs` 真去走一遍 import 闭包(种子 = `src/index.mjs` +
源码里 `new URL('./x.mjs', import.meta.url)` 这类**按路径 fork 的子进程入口**)。
★ 顺带纠正 pi 的规则表述:他写的是"被 `src/` import",但实测 22 个 `lib/` 模块里
有 4 个 `src` **直接**引用数是 0 —— `addressing.js`(被 `lib/inbox-format.js` 引)、
`user-question.js`(走前缀动态 import)、`mail-session-id.js`、`crash-notify.mjs`。
**直接引用数不是可达性**,所以判据真走图而不是 grep。
★ 也纠正他的排除清单名字:脚本里没有 `EXCLUDE_DIRS` 这个变量,就是一条 `rm -rf`。
## 本规则多抓到一个 pi 没发现的
`lib/user-question.js` 也是**只被测试引用**(只有 `test/user-question.test.mjs` 用它)
⇒ 同样会进快照。已一并移到 `test/lib/`。剩下 `mail-session-id.js` 与
`crash-notify.mjs` 是**谁都不用**(生产与测试都不可达)—— 那是遗留物,
不动它们(不属本次范围),但记录在此。
## 新增:因果**无关**的运行期判据
`test/lib/run-suite.mjs`:跑套件并从**同一次运行的 TAP**里数结果行,任何用例名
出现两次就红。为什么需要:静态那条(测试文件不许互相 import)只能发现**已知成因**。
实测跨文件重名**不会被 runner 拦**:两个文件各写一个同名用例 ⇒
`# tests 2 / # pass 2 / # fail 0`,两句 `ok`,零警告。
判据锚在 `^(ok|not ok) <n> - <名字>`(**结果行**),不是"名字出现过"——
pi 先前那条 `grep -c '<名字>'` 给 4 是因为 TAP 里名字既出现在 `# Subtest:` 头、
又出现在结果行,**2 倍效应 + 2 倍噪声恰好同值**,若行种类是 3 就会把两次读成三次。
本脚本自带 `--self-check`(干净样本放行 / 重复样本点名 / 只出现在头里的不算重复 /
名字含 `#` 不被截断)。
`package.json` 的 `test` 改为:
node test/lib/env-preflight.mjs && node test/lib/run-suite.mjs
## 判据全进套件
`test/layout-boundaries.test.mjs`(新):生产可达性不碰 `test/`、`test/lib/` 里不许藏
运行时模块、测试文件不许互相 import、`npm test` 必须接上 run-suite 那一层。
原来放在 `env-guard.test.mjs` 里那条"夹具不在测试文件里"已移到这里(集中边界判据)。
## 变异自检(两条都实测红了才留下)
- 造一个与巨行用例**同名**的探针文件 ⇒ `npm test` exit 1 并点名
`2× ★巨大的 message 行不进内存也不影响解析`;
- 往 `src/gateway.mjs` 加一行指向 `test/lib/run-suite.mjs` 的真 import ⇒
边界判据红并指出 `生产可达了测试代码:test/lib/run-suite.mjs`。
两条探针均已删除、`src/gateway.mjs` 用 `git checkout` 还原并 `cmp` 校验一致。
顺带修一处路径:`env-guard.test.mjs` 里 `PREFLIGHT` 仍指向旧的 `test/env-preflight.mjs`
(前置脚本已移入 `test/lib/`)。
验证:`npm test` **462/462**、结果行重复检查 0 个重名、set 全绿。
173 lines
6.8 KiB
JavaScript
173 lines
6.8 KiB
JavaScript
#!/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) <n> - <name>` —— **结果行**。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 字节不是「不知道」'],
|
||
},
|
||
];
|
||
|
||
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} 条失败。`);
|
||
process.exit(bad === 0 ? 0 : 1);
|
||
}
|
||
|
||
if (process.argv.includes('--self-check')) selfCheck();
|
||
|
||
// ── 跑套件 ────────────────────────────────────────────────────────
|
||
// `--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] 套件全绿,且没有重名。');
|