Files
MailUI4Agents/client/electron/test/run-all.mjs
JianFeeeee a8ac2fc28b test(criteria): 闭环——判据自报条数 + 每文件期望条数(只增不减的棘轮)
pi 指出的残余缺口:我上一轮加的自检 4 是**文本证据**(文件里有 `test(` / `check(` /
`process.exit(1)`),只能证明"**有能红的路径**",不能证明"**它跑过**"。反例很短:

```js
const check = () => {};     // 实现被换空(现实形态:合并冲突改坏实现)
check('a', false);          // 存在、也执行了,但什么都不会红
console.log('主题:通过');   // 有输出
```

## 落地(pi 给的闭环形状)

1. 自定义 `check()` 的判据结尾打一行机器可读汇总 `RESULT pass=<条数> fail=<失败数>`
   (`node:test` 的判据不用改,已有 `# pass N`);
2. `run-all.mjs` **只解析这个固定 marker**(不猜口语汇总——「窄屏布局:全部通过」里没有数字,
   按数字猜会误报,这一点我上轮已经实测过);
3. 与清单里登记的**期望条数**比对,**低于 → 红**。

关键细节:**计数写在 `check()` 内部**(theme/background 原本就在内部 ++;
narrow-layout 只有 failed 计数,补了 passed;markdown-xss 按 payload 条数算)。
写在调用点或靠扫源码的话,"实现被换空"就看不见了。

棘轮"只增不减":加判据**不用**改那个数,只有"条数掉了"才红。期望值按**实测**回填
(9/52/8/30/42/15/28/5/23/5/3/2)。

附带的可见性收益:这几轮我一直用"13→14""19→28""34→42"当信号,现在它成了判据 ——
某次改动顺手删掉两条判据、或某条被跳过,会立刻红。

## 变异

- pi 那个反例(`check` 换成空函数)→ 红(`自报 0 条 < 登记的 30 条`);
- 删掉 5 条 `check(` 调用 → 红(`自报 47 条 < 登记的 52 条`)。

## 规范

§6.5 新增"涉及运行时行为的结论必须实测过才能写进规范/判据"——同一个错这轮犯了两次
(我从"报告 0 个测试、退出 0"推断"退出码被吞",实测是照传;pi 拿我这个结论又建了一个洞)。
规则:**一次观察只支撑你看到的那一层**。
§6.6 记闭环形状与代价(故意删判据要同步改数字,属于一次可复核的显式编辑)。

⚠️ 并且如实记下一次**我自己违反规范**的事:写 §3 那条"变异后别用 `git checkout` 还原"的人
(就是我)在这次变异验证里又用了 `git checkout -- <文件>`,把刚加、尚未提交的 marker 抹掉了。
规矩写下来不等于会遵守 —— 已把这条实例写进规范,让人知道它是活人踩的坑。

## 验证

`npm test` 退出码 0(12 个判据文件全绿 + vitest 258/258);`run-all` 单独跑也 exit 0。
2026-09-14 15:06:14 +08:00

194 lines
9.7 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.

/**
* 判据总入口 —— **全部跑完再算退出码**。
*
* 为什么不再用 `&&` 串起来:
*
* 原先 `npm test` 是 `a && b && c …`。这种行为有个不起眼但很贵的后果 ——
* **前面红一条,后面全部不跑**。于是"只红了一条"看起来像"只有一个问题"
* 实际上后面那些判据连跑都没跑(这次就真发生了:`background` 红着,
* `packaging` 从来没跑到过,而它正是能发现"界面改了没重打包"的那条)。
* 换句话说:`&&` 链下的"全绿"是可信的,**"红"是不可信的**。
*
* 现在:每条判据都跑,红的收集起来,最后一起报、一起退出。
*
* 另外两条防"判据自己不会跑"的自检(与 process.exit 之后写判据是同一族问题):
* 1. 清单里的文件必须存在(名字写错 = 静默跳过一条判据);
* 2. `test/` 下的每个 `*.test.mjs` 都必须在清单里
* —— 这次 `cross-client-theme.test.mjs` 就是"写好了但没接进套件"
* 在它进套件之前一直是隐身状态。加了这条,**新增判据忘了接线会直接红**。
* 3. 判据规范 `test/CRITERIA.md` 要在、且要点到那几条规则
* —— 写判据的规矩本身也会被"忘了带"(形状记在某个人的脑子里等于没有)。
*
* 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
/**
* 判据清单:[文件, 额外 node 参数]。
*
* `--test` 给用 node:test 写的判据;鸿蒙那条要 `--experimental-strip-types`
* 才能直接执行 `client/harmony/.../MailGrouping.ts`(判据跑的是客户端真正引用的那份逻辑)。
*/
/*
* 自检 3判据规范在不在、有没有写到那几条关键规则。
*
* 为什么把"文档"也判:`CRITERIA.md` 里的每条都是踩出来的(窗口式判据、邻接式判据、
* 生成的清单被侵蚀、剥注释读不到理由……)。规则只在某个人的脑子里时,下一个人会重踩一遍;
* 文件被删/被搬走却没人发现,等于规则也没了。这里只断"还在 + 关键条目还在"
* 不断它的措辞 —— 那是笔记,不是接口。
*/
const CRITERIA_DOC = join(HERE, 'CRITERIA.md');
if (!existsSync(CRITERIA_DOC)) {
console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)');
process.exit(1);
}
const criteriaDoc = readFileSync(CRITERIA_DOC, 'utf8');
for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层']) {
if (!criteriaDoc.includes(must)) {
console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`);
process.exit(1);
}
}
const SUITE = [
['test/markdown-xss.test.mjs', [], 9],
['test/narrow-layout.test.mjs', [], 52],
['test/nav-merge.test.mjs', [], 8],
['test/theme.test.mjs', [], 30],
['test/background.test.mjs', [], 42],
['test/cross-client-theme.test.mjs', [], 15],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28],
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts纯逻辑所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23],
['test/build-stamp.test.mjs', [], 5],
['test/packaging.test.mjs', [], 3],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2]
];
// 自检 1清单里的文件必须真的存在写错名字 = 那条判据永远不跑)
const ghosts = SUITE.map(([f]) => f).filter((f) => !existsSync(join(ROOT, f)));
// 自检 2test/ 下每个 *.test.mjs 都要在清单里(防"写好了没接线"
const onDisk = readdirSync(join(ROOT, 'test'))
.filter((f) => f.endsWith('.test.mjs'))
.map((f) => `test/${f}`);
const unwired = onDisk.filter((f) => !SUITE.some(([s]) => s === f));
if (ghosts.length || unwired.length) {
if (ghosts.length) console.error(`清单里的判据文件不存在:${ghosts.join('、')}`);
if (unwired.length) {
console.error(`这些判据文件没接进套件(写了却不会跑):${unwired.join('、')}`);
}
process.exit(1);
}
/*
* 自检 3判据不得写在 `process.exit()` **之后**pi 提议2026-09-14
*
* 自检 1/2 管的是"文件没接线",管不到"检查写在了退出之后" —— 而那正是实际发生过的
* 第 4 例4 条玻璃判据被并发写入落到了文件末尾、`process.exit()` 后面,
* 于是**一条都不执行、也不计入通过/失败**,输出看起来完全正常。
* 这种事的成因是结构性的(并发写入总是往文件末尾追加),所以它一定会再发生,
* 而它下一次仍然不报错 —— 静态扫一遍最省事。
*/
const buried = [];
for (const [file, flags] of SUITE) {
if (flags.includes('--test')) {
continue; // node:test 那几条没有 process.exit结构上不会踩这个
}
const src = readFileSync(join(ROOT, file), 'utf8');
const exitAt = src.lastIndexOf('process.exit(');
if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) {
buried.push(file);
}
}
if (buried.length) {
console.error(`判据写在 process.exit() 之后,永远不会跑(挪到汇总之前):${buried.join('、')}`);
process.exit(1);
}
/*
* 自检 4pi 2026-09-14 提的家族,第 6 例):**"判据自己不会跑"**。
* 第 6 例的宿主是 runner 自己:清单里的 flag 与判据写法如果配错,症状是"看起来全绿"。
*
* ⚠️ 落地前先实测了两条真实样本,结论与 pi 的猜测**不同**,记在这里免得后人重猜:
* - `node --test <自定义 check() 的判据>`**退出码照样传出来**(文件 exit 1 → 命令行 exit 1
* 并没有被 runner 吞掉;
* - 但 `node --test <什么都不做的文件>` 会报 `# tests 1 / # pass 1` ——
* **计数不是"检查跑过"的证据**。所以"解析 pass 计数、0 就判红"这条路既
* 抓不到空判据(它报 1又会在 `narrow-layout`(汇总行"全部通过"里没有数字)上误报。
*
* 换成**结构证据**:每条判据文件里必须存在"能红"的路径 ——
* node:test 的 `test(`、自定义 `check(`、或显式 `process.exit(1)`。
* 一个都没有 = 它永远不会红,与"全通过"长得一模一样。
* 再加一条"跑完必须有输出"12 条判据现在都有输出),静默成功同样可疑。
*/
function shapeOf(file) {
const src = readFileSync(join(ROOT, file), 'utf8');
const usesNodeTest = /from 'node:test'/.test(src);
const canFail = usesNodeTest
|| /(^|[^.\w])check\(/.test(src)
|| /process\.exit\(\s*1\s*\)/.test(src);
return { usesNodeTest, canFail };
}
const shapeless = [];
for (const [file, flags] of SUITE) {
if (flags.includes('--test')) {
console.error(`清单里不要手写 --test它由文件内容推导${file}`);
process.exit(1);
}
if (!existsSync(join(ROOT, file))) continue; // 自检 1 已经报过了
if (!shapeOf(file).canFail) shapeless.push(file);
}
if (shapeless.length) {
console.error('这些判据文件里找不到任何"能红"的路径test( / check( / process.exit(1)'
+ `${shapeless.join('、')} —— 它们永远不会红,与"全通过"看起来一样`);
process.exit(1);
}
const reds = [];
for (const [file, flags, expected] of SUITE) {
console.log(`\n========== ${file} ==========`);
const shape = shapeOf(file);
const all = shape.usesNodeTest ? [...flags, '--test'] : flags;
// 收集输出再自己打回去观感不变stdio:'inherit' 的等价物),但能顺手做"跑了吗"的检查
const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8' });
const out = (r.stdout || '') + (r.stderr || '');
process.stdout.write(r.stdout || '');
process.stderr.write(r.stderr || '');
// 不 break后面每条都要跑出来否则"红了几条"这个信息本身是假的
if (r.status !== 0) reds.push(`${file}(退出码 ${r.status}`);
else if (out.trim().length === 0) reds.push(`${file}(跑完没有任何输出 —— 判据没跑起来的典型症状)`);
else {
/*
* 自报条数(闭环):自定义 check() 打 `RESULT pass=N fail=M`node:test 打 `# pass N`。
* 只解析**固定 marker**,不去猜口语汇总(「窄屏布局:全部通过」里没有数字,
* 靠猜数字的写法会误报 —— pi 提过,我也先贴过真实样本)。
*/
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out);
const nodeTest = /^# pass (\d+)/m.exec(out);
const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null);
if (ran === null) {
reds.push(`${file}(没找到自报条数 —— 判据要打 RESULT pass=N 或走 node:test 的 # pass N`);
} else if (expected > 0 && ran < expected) {
reds.push(`${file}(自报 ${ran} 条 < 登记的 ${expected} 条 —— 条数掉了:判据被删/被跳过/check 实现被改坏?)`);
}
}
}
console.log(`\n========== 判据汇总 ==========`);
if (reds.length === 0) {
console.log(`全部通过(${SUITE.length} 个判据文件:${SUITE.map(([f]) => f.replace('test/', '').replace('.test.mjs', '')).join('、')}`);
process.exit(0);
}
console.error(`红的判据(${reds.length}/${SUITE.length}`);
for (const r of reds) console.error(` - ${r}`);
process.exit(1);