Files
MailUI4Agents/plugins/pi-mail-bridge/test/lib/run-suite.mjs
JianFeeeee fb85a8728d refactor(pi-bridge): 定下 lib/ 与 test/lib/ 的边界 —— 三个测试侧模块原来会随部署进 /opt
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 全绿。
2026-09-14 20:00:16 +08:00

173 lines
6.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.

#!/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] 套件全绿,且没有重名。');