Files
MailUI4Agents/plugins/pi-mail-bridge/test/layout-boundaries.test.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

74 lines
4.4 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 assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative, resolve } from 'node:path';
import { reachable, productionSeeds } from './lib/reach.mjs';
const HERE = dirname(fileURLToPath(import.meta.url)); // …/test
const ROOT = resolve(HERE, '..');
test('★生产代码不得引用 test/ 下的文件(`lib/` 与 `test/lib/` 的边界)', () => {
// 规则写成**可判定的**形式:从生产入口(`src/index.mjs` + 按路径 fork 的子进程
// 入口)出发的 import 闭包里,不许出现 `test/` 下的任何文件。
//
// 为什么需要:`lib/tmp-space.mjs` 等三个模块只有测试用,却住在 `lib/` 里
// ⇒ `cp -a "$SRC/." "$STAGING/"`(只排掉 `test/`)把它们**打进生产快照**。
// 后果不是几 KB,而是"漂移 N 处"这个数字**虚高**、哈希清单变长 ——
// 而我们刚把"手抄哈希清单"定性为会过期的东西。
const reach = reachable(productionSeeds());
const offenders = [...reach].filter((p) => p.startsWith('test/'));
assert.deepEqual(offenders, [], `生产可达了测试代码:${offenders.join('、')}`);
// 反向:闭包必须真的走起来了(空集合也能让上面那条绿 —— 反面样本只挡一半)。
assert.ok(reach.has('src/index.mjs'), '入口必须在闭包里');
assert.ok(
reach.has('src/worker.mjs'),
'worker 是按路径 fork 的子进程入口(不是 import 进来的),漏掉它就是漏掉生产代码'
);
assert.ok([...reach].some((p) => p.startsWith('lib/')), '至少有一个 lib/ 模块可达');
});
test('★`test/lib/` 里的模块只被测试引用(别把运行时模块藏进去)', () => {
// 反向的边界:`test/lib/` 是给测试用的,若某个**生产可达**的模块住在里面,
// 那它部署后就会缺失 —— 这是个会等到重启才炸的错(白名单失败的经典形状)。
const reach = reachable(productionSeeds());
const inTestLib = readdirSync(join(HERE, 'lib'))
.filter((f) => /\.(mjs|js)$/.test(f))
.map((f) => `test/lib/${f}`);
const wrong = inTestLib.filter((p) => reach.has(p));
assert.deepEqual(wrong, [], `这些模块生产可达,不该放在 test/lib/:${wrong.join('、')}`);
});
test('★测试文件之间不许互相 import(会二次注册整套用例)', () => {
// `node --test` 默认每个文件一个子进程,而模块导入是**进程内**的 ⇒
// 从另一个 `.test.mjs` 取东西,会把那个文件的用例在引用者进程里**再注册一遍**。
// 实测:巨行用例(单条往临时目录写 ~12 MiB)跑了两次,测试总数 475(修完 459)。
//
// 判据形状:解析**真引用**(静态 `from` / 动态 `import()` / `require()`),
// 注释里提到文件名不算(注释不会注册用例)。
// 两版都踩过,写在注释里免得重踩:只匹配静态 from ⇒ 漏动态导入(过窄);
// "文件里出现别的测试文件名" ⇒ 把散文引用也算违规、还被自己注释里的示例点亮(过宽)。
const refRe = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)['"]\.\/([\w.-]+\.test\.mjs)['"]/g;
const offenders = [];
for (const f of readdirSync(HERE)) {
if (!f.endsWith('.test.mjs')) continue;
const src = readFileSync(join(HERE, f), 'utf8');
for (const m of src.matchAll(refRe)) offenders.push(`${f} → ${m[1]}`);
}
assert.deepEqual(offenders, [], `测试文件之间不许互相 import:${offenders.join('、')}`);
});
test('★`npm test` 必须跑"从同一次运行的 TAP 里数重名"那一层', () => {
// 因果**无关**的判据:跨文件重名不会被 runner 拦(实测两个文件各写一个同名用例
// ⇒ `# tests 2 / # pass 2 / # fail 0`,两句 `ok`,没有任何警告)。
// 静态那条只能发现"已知成因"(互相 import),这条不挑成因。
//
// 这里只验**接线**(脚本在 package.json 里被调到、且它自带自检),
// 因为真跑一遍套件就是 `npm test` 本身在做的事 —— 在套件里再跑一次套件会递归。
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
assert.match(pkg.scripts.test, /test\/lib\/run-suite\.mjs/, 'npm test 必须走 run-suite');
assert.match(pkg.scripts.test, /test\/lib\/env-preflight\.mjs/, '前置自检也必须还在');
});