Files
MailUI4Agents/client/electron/test/run-all.mjs
JianFeeeee 9a10ab2fcf 跨端: fix(harmony-appearance): blurStyleFor 已删除 ⇒ 换成**碑文判据 + 性质现居地**(不是只删);顺手修两处说谎的标题
pi 2026-09-15 查到根因:`blurStyleFor`(px → 材质档)已**有意删除**(碑文 `Wallpaper.ts:246`:
"没有、也不该有消费者"),而判据文件里还有引用、2 条真在调用 ⇒ `A.blurStyleFor is not a function`。

**只删会把"这个性质搬到哪了"一起删掉**(pi 的建议)。所以两处改成:

1. **碑文/反回归**:`typeof A.blurStyleFor === 'undefined'` —— 有意删除的东西不许悄悄回来;
   外加"理由必须留在原处"(碑文正则,删了理由也会红);
2. **性质的现居地**:导航面板材质现在是**固定系统档** ——
   `common/Theme.ets` 的 `navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK`(从"函数映射"搬到"常量")。
3. 后半段那组(分档边界/单调性/NaN)**整体作废且不硬造等价物**:它们的**主语**没了,
   常数没有单调性、没有越界归并、没有 NaN 方向 —— **性质随主语一起消失,判据变少是对的**。

★ 另修**两处说谎的标题**(本判据属于 pi 的"标签必须等于断言范围"):
- `★ blurStyleFor:分档边界、单调性、NaN 都是行为` → `★ 碑文:blurStyleFor 不许回来 + 理由必须留在原处`;
- `★ 模糊值映射到**系统材质档次**` → `★ 材质档**不再是 px 的映射**:碑文 + 性质现居地`。
  改前它们断言的内容与标题已经完全不是一回事。

实测:`# pass 25 / # fail 0`(此前 退出码 1)。登记 26 → 25 同步(**变少是对的**)。
2026-09-15 12:20:41 +08:00

748 lines
44 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 { prose, stripComments } from './lib/read.mjs';
import { spawnSync } from 'node:child_process';
import { existsSync, 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 = prose(CRITERIA_DOC);
for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) {
if (!criteriaDoc.includes(must)) {
console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`);
process.exit(1);
}
}
const SUITE = [
['test/markdown-xss.test.mjs', [], 9],
['test/narrow-layout.test.mjs', [], 64],
['test/nav-merge.test.mjs', [], 8],
['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion
['test/theme.test.mjs', [], 30],
['test/background.test.mjs', [], 42],
['test/cross-client-theme.test.mjs', [], 15],
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 5],
['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'], 25],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 11],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 4],
['test/build-stamp.test.mjs', [], 7],
['test/packaging.test.mjs', [], 5],
['test/align-refs.test.mjs', [], 3],
['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8],
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13],
['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23],
['test/debt-visibility.test.mjs', [], 1],
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口
['test/criteria-hygiene.test.mjs', [], 5],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 22],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 29],
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
['test/harmony-arkts.test.mjs', [], 3]
];
// 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)
const ghosts = SUITE.map(([f]) => f).filter((f) => !existsSync(join(ROOT, f)));
// 自检 2:test/ 下每个 *.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 = prose(join(ROOT, file));
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);
}
/*
* 自检 4(pi 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 = prose(join(ROOT, file));
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 = [];
/*
* 判据**条数**的累计(pi 2026-09-15)。
*
* 为什么要有它:套件此前**不打总数** —— 于是每次要"一共多少条、过了多少条",
* 都得在套件外面用 `node --test test/*.test.mjs` 那样的 **glob 调用法**绕出去。
* 而 glob 那套正是让"写了判据却没接线"逃掉的那个调用法(那次 `SUITE` 里少两个文件,
* 绕出去的 glob 反而把它们跑绿了)。⇒ 权威来源必须由**套件自己**给出:
* 它只累加**它自己实际跑过并解析到自报条数**的那些文件,不猜、不扫目录。
*/
let totalTests = 0;
/** 逐文件自报的**失败**条数之和(`fail=` 那一格)。`pass + fail == checks` 是恒等式 */
let totalFail = 0;
/**
* **实际跑了但没能自报条数**的文件数 —— 这个数必须显式打出来。
* 没有它,`checks=` 会**静默少算**(跑过的文件里有多少条没被计入,读者看不出来)。
* 我第一次量就撞上了:`checks=379` 而逐文件手算合计 386,差的 7 条正是这些文件里的。
*/
let unreportedFiles = 0;
/** 实际跑过(并解析到自报条数)的文件数 —— 与 `SUITE.length` 分开报,差额看得见 */
let ranFilesCount = 0;
/*
* ─── 相位(pi 2026-09-14 裁定)───
*
* 同一份套件会被**两个不同相位**调用:
* · 构建/发布相位(`npm test`、`--release`):问"我要产出的东西是不是从**当前源码**新鲜构建的?"
* → `packaging`(包内 dist == 当前 dist)、`build-stamp`(产物 == HEAD)在这一相位才有意义;
* · 安装相位(`deploy/install.sh`,见 `AGENTMAIL_CRITERIA_PHASE=install`):
* 问"**别人已经产出的这个东西**能不能装到这台机器上?" —— 这里"dist 与源码树的关系"
* 根本不是该问的问题:部署路径不重新打包(也**不应该**重新打包,否则"review 过的产物"
* 和"装上去的产物"不是同一个东西,而且会重新构建的干跑不是干跑)。
*
* 所以规则(可机检):**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖
* "源码树是最新构建的"这个前提。** 一条门如果在某个相位恒红或恒绿,那不是门的问题,
* 是**相位挂错了** —— 今天撞到三条:`check-shared-libs`(从 153985e 起恒红)、
* `packaging`(前端一改就卡死)、`HOME: unbound variable`(在门禁跑完之后才炸)。
*
* 下面登记每条判据读的是哪一侧;`install` 相位里出现 `SOURCE` 侧判据 → 直接红。
*/
const SIDE = {
ARTIFACT: 'artifact', // 只读产物 / 目标机状态(装得上装不上)
SOURCE: 'source' // 依赖"源码树是最新构建的"(构建相位才成立)
};
/** 判据文件 → 它读的那一侧。没登记的按 ARTIFACT(读源码的必须显式登记,否则会被这条例外掉) */
const SIDES = {
'test/build-stamp.test.mjs': SIDE.SOURCE, // 产物 gitRev/srcHash == 当前源码树
'test/packaging.test.mjs': SIDE.SOURCE // 包内 dist == 当前 dist
};
const PHASE = process.env.AGENTMAIL_CRITERIA_PHASE === 'install' ? 'install' : 'build';
function sideOf(file) { return SIDES[file] || SIDE.ARTIFACT; }
const brokens = [];
const skippedByPhase = [];
for (const [file, flags, expected] of SUITE) {
if (PHASE === 'install' && sideOf(file) === SIDE.SOURCE) {
skippedByPhase.push(file);
continue;
}
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 || '');
/*
* `broken` 与 `red` 必须分开(pi 2026-09-14 §3)。
*
* 我这轮两次把"跑不起来"当成"判据红了":一次变异注入少了 import(报 build failed)、
* 一次判据里写了没绑定的标识符(ReferenceError 抛在判据自己身上)。
* 两者的后果都是同一种骗人方式:**看起来像判据失败,其实是判据没跑**。
* 所以判据是:退出码非零 **且输出里没有一句"断言失败"** → 那是 broken(崩了),不是 red。
* 变体验证里出现 broken = **这次变异无效,重做**,不许记成"红过了"。
*/
/*
* **别解析人类可读的文本**(pi 2026-09-14 §5):文本格式是运行器的实现细节。
* 第一版我用"输出里有没有 `not ok`"判崩,当场误判 —— node:test 把**导入期**的
* `ReferenceError` 也报成 `not ok 1 - …`,于是"崩了"和"断言失败"在文本上同形。
*
* 现在按 TAP 的**名字**判,而不是按错误正文猜:文件级失败的测试名就是**文件路径**,
* 而真正的断言失败名字是判据名。所以:
* 所有 `not ok` 的名字都像路径(含 .mjs / 以 / 开头)→ **broken(判据没跑起来)**;
* 只要有一个名字像判据名 → **red(判据说不成立)**。
* 再叠一层语言级崩痕只是兜底(没有 `not ok` 却退出非零的情形)。
*
* 我不再往里加"更多错误关键字"——那是往文本解析里加补丁,方向是错的。
*/
const notOkNames = [...out.matchAll(/^not ok \d+ - (.*)$/gm)].map(m => m[1].trim());
const looksLikePath = n => /(^\/|\.mjs\b|\.ts\b|\.js\b)/.test(n);
const assertionFailed = notOkNames.some(n => !looksLikePath(n));
const onlyFileLevel = notOkNames.length > 0 && !assertionFailed;
const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/;
const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out)));
const empty = out.trim().length === 0;
/*
* ── 条数累计:**必须在红/绿分支之外** ──
*
* 原来它写在下面 `else { … }`(只在**绿**时进入)里,于是**红文件一条都不计入**,
* `checks` **静默少算**。实测:`checks=379` 而逐文件手算 387 —— 差的那些正是红文件里的。
* **"红"只说明判据说"不成立",不说明它没跑**;跑过的条数一样要算进来。
*
* 单位是自报的条数本身(`RESULT pass=N fail=M` 的 N+M,或 `# tests N`),
* 两种文件的"条"不是同一种条,所以汇总里那一格叫 `checks` 而不叫 `tests`。
*/
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out);
const nodeTest = /^# pass (\d+)/m.exec(out);
const nodeFail = /^# fail (\d+)/m.exec(out);
const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null);
if (ran !== null) {
totalTests += ran;
ranFilesCount += 1;
totalFail += marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0);
} else {
unreportedFiles += 1;
}
// 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的
if (crashed || empty) {
const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160);
brokens.push(`${file}(${empty ? '跑完没有任何输出' : `退出码 ${r.status},但没有一句断言失败`})` +
(firstErr ? `\n ↳ ${firstErr}` : '') +
'\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' +
'\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过。' +
'\n 用于变体验证时:broken **不算这次变异有效**,要重做。');
} else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`);
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 nodeFail = /^# fail (\d+)/m.exec(out);
const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null);
// 累计总数(见汇总末尾的 `RESULT files=…`)—— 解析成功就加,无论该文件红绿
if (ran !== null) {
totalTests += ran;
ranFilesCount += 1;
totalFail += marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0);
} if (ran === null) {
unreportedFiles += 1;
/*
* 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 ——
* 并发写 WebUI 的 agent 新加判据时不会打开 CRITERIA.md,看到红的第一反应
* 很可能是"套件坏了"(删自检、往清单里塞豁免)。**red 是 ta 一定会看到的东西,
* 文档不一定会被打开** —— 所以把修法直接写进这条错误里,并给出可抄的样板。
*/
reds.push(`${file}
↳ 没找到自报条数。修法(二选一):
1) 用共享 helper(新判据推荐):
import { check, finish } from './lib/checks.mjs';
check('判据名', 条件, '失败时给人看的细节');
finish('标签'); // 它负责打 RESULT pass=N fail=M
样板:test/markdown-xss.test.mjs、test/narrow-layout.test.mjs
2) 自己打一行(老写法,计数必须写在 check() 内部,否则"实现被换空"看不见):
console.log(\`RESULT pass=\${pass} fail=\${fail}\`);
样板:test/theme.test.mjs、test/background.test.mjs
(用 node:test 写的判据不用管:runner 认 \`# pass N\`。)`);
} else if (expected > 0 && ran !== expected) {
// **相等**,不是"不少于"(pi 邮件 `9fed4386`):只判下界时,**多加的条数不受保护** ——
// 下一个人删掉那条"多出来的"判据不会有任何东西变红,于是"被删会红"这个性质只覆盖前缀。
// 两个方向的成因与处置都不同,所以分开写。
const why = ran < expected
? `自报 ${ran} 条 < 清单里登记的 ${expected} 条。常见成因:判据被删/被跳过(写在 process.exit() 之后、
条件里提前 return)、check() 的实现被改坏(合并冲突)、marker 打在了汇总之前但计数没接上。
确认确实该减少条数时,把清单里那个数字一起改掉(那是一次显式、可复核的编辑)。`
: `自报 ${ran} 条 > 清单里登记的 ${expected} 条 —— 新加的那几条**不在"被删会红"的保护内**:
只判下界时,删掉多出来的部分不会有任何东西变红。请把清单里这个数字改成 ${ran}(一次显式编辑),
新判据才真正被登记保护。`;
reds.push(`${file}\n ↳ ${why}`);
}
}
}
/*
* ─── 静态判据的**欠账**与到期机制(pi 2026-09-14 提议) ───
*
* 有些判据只能验**形态**(读 `.ets` 源码),因为它们要验的东西在本机跑不起来:
* 鸿蒙侧编译要 hvigorw、运行要设备/模拟器。这类判据登记在下面,并各自写清
* **什么前提一旦成立它就过期**。
*
* 为什么不能只写一句"暂时":**"暂时"不是一种状态,是一个待办** —— 规范里写下的
* "暂时"没有任何机制会回来读它,于是永远留在原地。这里把它变成可机检的形状:
* 1. `UNBLOCK` 必须是**可检测的前提**(这里就是"有没有可用设备"),不是陈述;
* 2. 汇总里打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见;
* 3. **前提一旦为真,欠账当场变红**:不等人想起来,设备可用的那天这些判据必须
* 改成行为判据(或明确降级并写理由)。
*
* 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管
* "它该升级了没有"。
*/
/**
* 探针:判定静态判据的"到期前提"是否成立。
*
* # 三值,不是两值(pi 2026-09-14 §2)
*
* 原先只有"成立/不成立"两种结果,于是**"探针跑不了"和"设备不可用"被归成同一格**:
* 设备那天真可用了,闸门也永远不会开 —— 机制在,闸门锈死,而且看起来完全健康
* (这是"自报 0 条 < 登记 30 条"的第三种形状:探针自己坏了,没人知道)。
*
* 所以:
* - `true` 可用(前提成立 → 依赖它的静态判据**到期**,必须处理);
* - `false` 不可用(探针**确实跑成了**,结论是没有目标);
* - `'unknown'` 拿不准(**命令在但跑不成**:超时、非零退出、抛异常)→ **按到期处理**。
*
* # 这条设计当场抓到了什么(写下来,因为它是"闸门锈死"的实证)
*
* 换三值之后第一次运行就报了 `probe=unknown` —— 一查:探针里调的是 `execFileSync`,
* 而这个文件 import 的是 `spawnSync`(**名字根本没定义**)。也就是说
* **探针从写下的那天起一次都没跑成过**,而旧的二值设计把 `ReferenceError`
* 连同"没有设备"一起吞掉、统一报成"设备不可用"——**机制在、闸门从来没开过,
* 而它看起来完全健康**。三值把它变成了一声明确的红:探针自己坏了,必须有人看一眼。
*
* 另外,`'unknown'` 只留给"命令在、但跑不成";**所有候选都不存在**(本机没装 hdc)
* 是可判的事实(没有工具就不可能有设备),报 `false`,否则没装 SDK 的机器会天天假红。
* 拿不准就红,让人看一眼 —— 这条判断比"猜一个"便宜得多。
*
* 测试用 `AGENTMAIL_PROBE_DEVICE=ok|none|unknown` 覆盖(判据自检要用:
* 没有这个开关就没法验证"unknown 会不会红")。
*/
const PROBES = {
device: {
// 前提写成"**本工作区**能装能点设备",不是"机器上有设备"(pi 2026-09-14):
// 同一台机器上设备对 gui-lab 那条通道可用、对我这条不可用 —— 写"机器上有设备"的话,
// 探针变真会让 5 条判据同时红,而**我修不了**(要写 /run、/var/log,在工作区外)。
// 写成"本工作区能装能点",红就只在我能动手时出现。
desc: '本工作区能装、能点设备(hdc 服务健康 + 看得到目标)',
need: [
'设备/模拟器要在本工作区里起得来:模拟器启动要写 /run/harmony-emulator.pid 与 /var/log/harmony-emulator.log',
'hdc 的**服务端**要健康(`hdc -m` 在跑);服务端起不来时 `list targets` 也会打印 [Empty],那是"拿不准"不是"没设备"',
'要有 hdc 目标;能装上 hap 并能点(hvigorw assembleHap + hdc install)'
],
run() {
const override = process.env.AGENTMAIL_PROBE_DEVICE;
if (override === 'ok') return true;
if (override === 'none') return false;
if (override === 'unknown') return 'unknown';
/*
* # 候选清单的权威性(pi 2026-09-14 §2:硬编码清单今天咬了两次)
*
* 上一版在**固定两条路径**里找 hdc,全不在就判"本机没装 → 没有设备"(false)。
* 这与 build.sh 在固定几个根里找 SDK 源码、第一个命中就用,是**同一个形状**:
* 硬编码清单 + "命中/未命中"当真值。那次命中了 /root/ha-test 的老 checkout,
* 于是把错误结论当成了事实。所以这里:
* ① 候选来源写清楚(下面 TOOLCHAIN_ROOT 是华为命令行工具的**文档安装根**);
* ② **工具链根在、里面却没有 hdc** → `unknown`(不是"没装",是"装了但长得不一样");
* ③ `hdc` 也走 PATH(spawnSync 自己会查 PATH),不再只认那一条写死的路径。
*/
const TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools';
const sdkHdc = `${TOOLCHAIN_ROOT}/sdk/default/openharmony/toolchains/hdc`;
const candidates = [sdkHdc, 'hdc'];
let ranOnce = false; // 至少有一条命令**跑成过**(哪怕是"没有目标")
let sawEmpty = false;
let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— "拿不准"
let allMissing = true; // 所有候选都不存在
for (const bin of candidates) {
const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 });
if (r.error && r.error.code === 'ENOENT') continue;
allMissing = false;
if (r.error) { anomaly = r.error.code || String(r.error.message || r.error); continue; }
if (r.status !== 0) { anomaly = `exit ${r.status}`; continue; }
ranOnce = true;
const t = (r.stdout || '').trim();
if (t && !/\[Empty\]/.test(t)) return true; // 明确可用
sawEmpty = true; // "没有目标"(下面还要问服务端健不健康)
}
/*
* `[Empty]` **不等于**"没有设备"(pi 2026-09-14 §1)。
*
* 它至少混三种原因:① 真没有设备;② **hdc 自己的服务端起不来**
* (socket/权限 —— 而"要写工作区外的目录"正是我这轮撞上的限制);
* ③ 被打印成空集的权限拒。所以先证服务端健康,才敢把空集当成"可判的没有":
* 服务端进程在(`hdc -m`,含它在别的路径下)→ 空集是可判事实 → false;
* 服务端找不到 → 空集可能正是"探针自己被环境弄坏了" → unknown(红)。
*/
if (ranOnce && sawEmpty) {
return hdcServerLooksHealthy() ? false : 'unknown';
}
if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准
if (allMissing) {
// 工具链根在、里面却没有 hdc → "装了但长得不一样",不是"没装"
return existsSync(TOOLCHAIN_ROOT) ? 'unknown' : false;
}
return 'unknown';
}
}
};
/**
* hdc 的服务端是不是在跑。只读 /proc/<pid>/cmdline(不依赖 `ps` 也不起子进程
* —— 探针自己不该再引入"命令跑不起来"这种不确定性。
* 判据:命令行里第二个参数是 `-m`(hdc 的 server 模式)。
*/
function hdcServerLooksHealthy() {
let pids = [];
try { pids = readdirSync('/proc').filter(d => /^\d+$/.test(d)); } catch { return false; }
for (const pid of pids) {
try {
// 走 lib/read.mjs 的 prose(判据目录不许裸 readFileSync —— 那条判据也管这里)
const cmd = prose(`/proc/${pid}/cmdline`).split('\0').filter(Boolean);
if (cmd.length && /(^|\/)hdc$/.test(cmd[0]) && cmd.includes('-m')) return true;
} catch { /* 进程刚退出 / 没权限读 —— 换下一个 */ }
}
return false;
}
/** 探针结论 → 是否等于"前提成立(到期)" */
const probeIsDue = v => v === true || v === 'unknown';
/** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */
/*
* 探针自检(`--probe-selftest`):它判的是**判据自己的分辨力** ——
* "unknown 到底会不会红"。没有这条,`probeIsDue` 哪天被改成 `v === true`
* 也没人会发现,而那正是"闸门锈死"的写法。
*/
if (process.argv.includes('--probe-selftest')) {
const cases = [
['ok 可用 → 到期', true, true],
['none 不可用 → 不到期', false, false],
['unknown 拿不准 → 到期(必须红)', 'unknown', true],
];
let bad = 0;
for (const [what, value, want] of cases) {
const got = probeIsDue(value);
console.log(`${got === want ? 'ok ' : 'RED '} ${what}(probeIsDue(${JSON.stringify(value)}) = ${got})`);
if (got !== want) bad++;
}
process.exit(bad ? 1 : 0);
}
const STATIC_ONLY = [
['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'],
['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'],
['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'],
['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'],
['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device'],
// P4c 同批的两条:判的是 `.ets` 里的页面/组件,本机没有设备也没有模拟器
['test/harmony-admin.test.mjs', '用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点(本机两者都没有)', 'device'],
['test/harmony-imageprep.test.mjs', '图片上传链:要 `@ohos.multimedia.image` + 相册 + 服务端,三样本机都没有', 'device']
];
for (const [file, , probe] of STATIC_ONLY) {
if (!SUITE.some(([f]) => f === file)) {
console.error(`✗ 静态判据登记里的 ${file} 不在套件清单里(登记要跟着套件走)`);
process.exit(1);
}
if (!PROBES[probe]) {
console.error(`✗ ${file} 的到期前提 \`${probe}\` 不是已知探针(UNBLOCK 必须是可机检的前提,不是一句陈述)`);
process.exit(1);
}
}
const probeResults = {};
for (const [, , probe] of STATIC_ONLY) {
if (probeResults[probe] === undefined) probeResults[probe] = PROBES[probe].run();
}
let selfCheckFailed = false;
/*
* 自检 4(pi 2026-09-15):**到期闸不许把汇总和其它红一起吃掉**。
*
* 起因:到期块原先在报文末尾 `process.exit(1)`,而它在汇总之前 ⇒ 只要它红,
* `RESULT phase=` 行、欠账余额、条数不符、**所有其它红**都不打(pi 实测 `harmony-presets` 那条真红一次没显示)。
* 修法是"记账 + 走完汇总",但**修法本身没有判据钉着** —— 谁改回去都不会被拦。这就是那条判据。
*
* ⚠️ 它验的是**套件自己的源码形状**,不是"跑起来真的没被吃":
* 标签只覆盖"源码里那两种写法不在了",不覆盖"运行时一定同现"。想验后者要跑两次完整套件,成本高。
*/
try {
const selfSrc = prose(fileURLToPath(import.meta.url));
const from = selfSrc.indexOf('let dueFailed = false;');
const to = selfSrc.indexOf('const probeSummary');
/*
* ⚠️ 必须**先去注释与字符串**再找 `process.exit(`:这条自检自己的报错文案里就有那串字,
* 不去掉的话它会**指着自己报红**(我第一次跑就是这样 —— 与 `@ohos`/`toISOString` 那两次同族:
* 扫描器咬到自己的文档)。规矩不变:**不靠挪扫描口径解决,靠去掉不该扫的部分**。
*/
const gate = from >= 0 && to > from ? stripComments(selfSrc.slice(from, to)) : '';
const probs = [];
if (!gate) {
probs.push('找不到到期闸区块(标记被改动)⇒ 这条自检**自己失效**,必须红 —— 不许静默通过');
} else {
if (/process\.exit\s*\(/.test(gate)) {
probs.push('到期闸区块里又出现了 `process.exit()` ⇒ 它会在汇总之前退出,把汇总与其它红一起吃#掉');
}
if (!/&&\s*!dueFailed/.test(selfSrc)) {
probs.push('汇总分支缺 `&& !dueFailed` 守卫 ⇒ 到期闸红时可能反过来误报"全部通过"(修一处引坏一处)');
}
}
if (probs.length) {
console.error('✗ 套件自检 4(到期闸不许吃掉汇总):');
for (const x of probs) console.error(` - ${x}`);
selfCheckFailed = true;
} else {
console.log('ok 4 - 到期闸是**记账**而不是 exit(汇总与其它红不会被它吃掉)');
}
} catch (e) {
console.error(`✗ 套件自检 4 跑不了:${e.message || e}`);
selfCheckFailed = true;
}
/*
* pi 2026-09-15:这块原先在报文末尾 `process.exit(1)`,而**它在汇总之前** ⇒
* 只要到期闸红了,`RESULT` 行、欠账余额、条数不符、以及**所有其它红**都不会打出来。
* 实测:`harmony-presets` 实报 6 / 登记 5 这条真红一次都没显示过。
* 形状:**一道闸把不属于它的证据一起吃掉**。修法不是"少红",是**记账 + 继续走完汇总**。
*/
let dueFailed = false;
const dueStatic = STATIC_ONLY.filter(([, , probe]) => probeIsDue(probeResults[probe]));
const unknownProbes = Object.entries(probeResults).filter(([, v]) => v === 'unknown').map(([k]) => k);
if (dueStatic.length > 0) {
const firstUnknown = unknownProbes.includes(dueStatic[0][2]);
console.error(firstUnknown
? `\n✗ 探针**跑不了**(${PROBES[dueStatic[0][2]].desc})—— 拿不准就按到期处理,别让闸门锈死:`
: `\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`);
for (const [file, why, probe] of dueStatic) {
console.error(` - ${file}(到期前提:${PROBES[probe].desc};当初只能静态的原因:${why})`);
}
console.error(
' 这些判据当时只能验形态。前提成立后必须做其中一件(别默默留着):\n' +
' a) 改成**行为判据**(真跑一遍/真点一次),静态那条降级或删掉;\n' +
' b) 明确写"为什么仍然只能静态"(如设备能编译但点不了),并改换一个更准的到期前提。\n' +
' 这是"暂时"的到期机制:它的作用就是不等谁想起来。'
);
/*
* 到期报文自带**要人做什么**(pi 2026-09-14 §2):
* 这条红第一次出现时,最可能的结局是"被当成噪音消掉"——因为看的人不知道要放行什么。
* 前提写的是"**本工作区**能装能点",所以这里把工作区外的门槛逐条列出来。
*/
const need = PROBES[dueStatic[0][2]].need || [];
if (need.length) {
console.error(' 要让它变成行为判据,需要先在本工作区打通:');
for (const n of need) console.error(` · ${n}`);
console.error(' 这些都在工作区外(需要人放行或提权)—— 所以这条红**不要求你现在修**,' +
'要求的是:别把它当噪音,并在打通后回来把静态那条升级掉。');
}
dueFailed = true;
}
/*
* 汇总里"欠账余额"和"探针是否健康"是两个不同的数字(pi §2):
* static=5 —— 还欠着 5 条只能验形态的判据;
* probe=ok —— 探针自己是好的(unknown 说明闸门可能锈死了,得人看一眼)。
*/
const probeSummary = unknownProbes.length ? 'unknown' : 'ok';
if (skippedByPhase.length) {
console.log(`相位=${PHASE}:本相位不判 ${skippedByPhase.length} 条**构建相位**判据(${skippedByPhase.join('、')})` +
`—— 它们问的是"产物是不是从当前源码来的",安装相位问的是"已产出的产物能不能装";` +
`安装相位判它只能读**产物自证**(见 deploy/install.sh 的前置检查)。`);
}
/*
* 欠账余额打进**常态可见**的这一行(pi 2026-09-14 裁定 §1/§3):
* "跳过"若不可数就等于不显形 —— 而 `go test` 跑通时不打印包的输出,
* 所以 Go 那边的 Skip 余额必须在**这边**可见。两边读**同一个**登记(docs/DEBTS.json),
* 于是"三笔欠账一处看全"且有余额、有到期。
*/
/*
* 欠账余额:**登记值与实测值一起打**(pi 2026-09-14 裁定 §1)。
*
* 为什么不能只打登记值:`docs/DEBTS.json` 是**手可编辑的副本**,而"可见的那个数字就是副本"
* 恰好违反我自己写进 §12 的那句 —— 比较的对象必须是权威来源,不是手边那份会过期的副本。
* 所以这一行同时给出:
* · `registered`:登记值(副本,谁都能改);
* · `measured`:**本相位能实测的部分**(这里有权威来源的只有 static:`STATIC_ONLY.length`);
* · 本相位测不了的(Go 侧那几笔)**明说由谁比对**,不冒充已实测。
* 不等即红:下面这条判据就是这一行(static 的登记值必须等于实测值)。
*/
const DEBTS_PATH = join(HERE, '..', '..', '..', 'docs', 'DEBTS.json');
let debtsLine = '';
let debtRegStatic = null;
try {
const led = JSON.parse(prose(DEBTS_PATH));
const total = led.debts.reduce((n, d) => n + d.count, 0);
const items = led.debts.map(d => `${d.id}:${d.count}`).join(',');
const regStatic = led.debts.find(d => d.id === 'static-criteria');
debtRegStatic = regStatic ? regStatic.count : null;
const measuredStatic = STATIC_ONLY.length;
/*
* 子集关系必须**打进字符串本身**(pi 2026-09-14 裁定 §2)。
* 上一版打的是 `debts=9(registered) measured_static=5==登记 ✓`:读的人先看到 9≠5,
* 而"本相位只有 static 有权威实测"只写在**信里**、不在那一行里 —— 于是那一行
* **没有一个字在判它可读**。理由与我写进 §12 的那条同源:
* **余额里任何需要靠注释才能读对的地方,就是下一个假绿。**
*/
const other = total - (debtRegStatic || 0);
const staticNote = debtRegStatic === null
? 'static=登记里没有这一项 ✗'
: debtRegStatic === measuredStatic
? `static-only=${measuredStatic}==登记 ✓`
: `static-only=${measuredStatic}≠登记${debtRegStatic} ✗`;
const env = led.debts.filter(d => d.kind === 'env').reduce((n, d) => n + d.count, 0);
debtsLine = ` debts=${total} (${staticNote} / other=${other} by go-test / env=${env} 等环境或人)(${items})`;
} catch (e) {
debtsLine = ` debts=登记读不到(${e.code || e.message})`;
}
/*
* 变异体计数**也进这一行**(pi 2026-09-15):此前它只活在信里,于是同一个量
* 换过 40/48/58/41 四种说法,而"job 集合是什么"没有定义 —— 复核方拿到的是一个
* "我复算不出你那个数、而且你的报数方法也不在场"的目录。
* ⇒ 口径写死在 `test/mutants/summary.py`(唯一权威),这里只**播报**它,
* 并顺着同一条"要提醒人的东西必须走默认路径"的规则。
* 跑不起来(summary.py 不在、python3 不在)**不静默** —— 明说"没跑",
* 免得下一个人把"这行没提"当成"没问题"。
*/
let mutantsLine = '';
try {
// ★ 路径要 join(HERE, …):`HERE` 是 `test/`(不是 `client/electron/`)——
// 我第一版写成相对 `client/electron/` 的 'test/mutants/summary.py',
// 于是 python 报 "can't open file …/test/test/mutants/summary.py"(status=2),
// 而这里只看到"没打出 RESULT"。**错误信息被吞掉**正是这行注释要提醒的事:
// 播报失败时要把 stderr 一起说出来,别只说"未知"。
const sp = spawnSync('python3', [join(HERE, 'mutants', 'summary.py')],
{ cwd: HERE, encoding: 'utf8', timeout: 30000 });
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout || '');
mutantsLine = m
? ` ${m[1]}`
: ` mutants=(summary.py 没打出 RESULT:status=${sp.status} ${(sp.stderr || '').trim().split('\n').slice(-1)[0] || ''})`;
} catch (e) {
mutantsLine = ` mutants=(跑不了 summary.py:${e.code || e.message})`;
}
console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length}${debtsLine} probe=${probeSummary}` +
(unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)`
: '(只能验形态的判据:到期前提成立就自动变红)') + mutantsLine);
console.log(`\n========== 判据汇总 ==========`);
/*
* 判据**条数**的权威来源(pi 2026-09-15)。
* 格式与其它 `RESULT` 行同族:机器可读、一行、**套件自己打的**。
*
* ⚠️ 单位要写清:`checks` 是**逐文件自报条数之和** —— 也就是套件在每跑完一个文件时
* 真正解析过、并据此判"有没有少跑"的那个数。它**混两种单位**:
* `check()` 文件自报 `RESULT pass=N`(N 条判据),`node:test` 文件自报 `# pass N`
* (N 个 test 用例)。两者都是"条",但**不是同一种条**,所以这里叫 `checks` 而不叫
* `tests`,免得下一个人把它跟 `node --test test/*.test.mjs` 的 glob 口径直接比 ——
* 那个口径只跑标准 `test/*.test.mjs`,**不跑套件里的构建相位**,也不含本次相位跳过。
* `pass + fail == checks` 恒成立(fail 是那些文件里的红条数)。
*/
const totalsLine = (verdict) => `RESULT files=${SUITE.length} ran=${ranFilesCount} ` +
`checks=${totalTests} pass=${totalTests - totalFail} fail=${totalFail} ` +
`broken=${brokens.length} unreported=${unreportedFiles} verdict=${verdict}`;
if (reds.length === 0 && brokens.length === 0 && !dueFailed && !selfCheckFailed) {
// 报**本相位实际跑了**几条,不报清单长度 —— 上面刚写完"不能声称自己做过的事",
// 这里如果打 SUITE.length 就是在同一行里犯同一个错(2 条构建相位判据被跳过了)。
const ranFiles = SUITE.map(([f]) => f).filter(f => !skippedByPhase.includes(f));
console.log(`全部通过(${ranFiles.length} 个判据文件:` +
`${ranFiles.map(f => f.replace('test/', '').replace('.test.mjs', '')).join('、')}` +
`${skippedByPhase.length ? `;另有 ${skippedByPhase.length} 条属构建相位,本相位不判` : ''})`);
console.log(totalsLine('green'));
process.exit(0);
}
// broken 先报:它比红更严重(红是"判据说不成立",broken 是"判据没说话")
if (brokens.length) {
console.error(`跑不起来的判据(${brokens.length}/${SUITE.length})—— 不是红,也不算过:`);
for (const b of brokens) console.error(` - ${b}`);
}
if (reds.length) {
console.error(`红的判据(${reds.length}/${SUITE.length}):`);
for (const r of reds) console.error(` - ${r}`);
}
if (dueFailed && reds.length === 0 && brokens.length === 0) {
console.error('另外:静态判据**到期**(报文见上)—— 它现在**不再吃掉**上面的汇总与其它红。');
}
console.log(totalsLine('red'));
process.exit(1);