Files
MailUI4Agents/client/electron/test/animation-audit.test.mjs
JianFeeeee a47b42b36e 修复: 套件读数不再取决于"你怎么调用它"—— 子进程钉 cwd: ROOT + read.mjs 相对路径按包目录解析(第二层);并把"崩了"从 red 里分出来(结构判据,不加错误关键字)
pi 2026-09-17 实测报的:**同一份代码、两种调用法、两个结论**。我复现并修了两层。

## 一、复现(同 HEAD,只换 cwd)

| 调用法 | 修前 RESULT |
|---|---|
| `cd client/electron && node test/run-all.mjs`(`npm test` 入口)| `ran=29 checks=440 red=8 unreported=0` |
| `node client/electron/test/run-all.mjs`(仓库根)| `ran=27 checks=348 red=10 unreported=2` |

差 **92 条**(= 88 + 4),红 8 → 10。两次都稳定复现。

**根因**:`run-all.mjs` 的 `spawnSync` **不传 `cwd`** ⇒ 子进程继承调用者的 cwd。
而全仓有 **12 处**判据按**包内相对路径**读文件(`code('src/index.css')` 这种)
—— 它们本来就假定 `cwd = client/electron`,只是没人把那个假定钉住。
从仓库根跑 ⇒ `ENOENT` **崩在 import 期**。

## 二、修(两层,各管一段)

1. **运行器**:`spawnSync(..., { cwd: ROOT })` —— 一行覆盖**现有的与将来的所有**判据。
   方向是"让运行器去满足判据的既有假定",不是改判据去迁就调用者。
2. **`read.mjs`**(第二层):`code/prose/bytes` 里的相对路径一律按**包目录**解析
   (基准取自 `import.meta.url`,**不是** `process.cwd()` —— 前者是代码里的结构事实,
   后者正是这个 bug 的成因)。pi 报的复现步骤是**直接跑单个判据文件**,
   那条路**不经过运行器**,所以这层不是重复。
   基准 `PKG` 也**导出**了:`readdirSync`/`existsSync` 不经过 `read.mjs`,
   各处自己拼一次就是"同一个事实多份实现"(本仓反复消的形状)。

**验证(三种 cwd × 两个相位,逐字节一致)**:

```
build   /home/program/agentmail          ran=29 checks=440 red=8 unreported=0
build   /home/program/agentmail/client/electron  同上
build   /                                同上
install /home/program/agentmail          ran=27 checks=428 red=7 unreported=0
install /home/program/agentmail/client/electron  同上
```
(三种 cwd 一致、且与 `npm test` 入口一致 ⇒ 不存在"另一种调用法给别的数"。)

## 三、★ 第二处:那两条不是"红了",是"没跑完"——而它被报成了 red

`narrow-layout` 从仓库根跑:**先打完 43 条"通过",然后第 44 条 ENOENT 崩掉**,
而 `CRASH_SIGNS`(关键字表)**不认 ENOENT** ⇒ `crashed=false` ⇒ 落进 `reds`:
`- test/narrow-layout.test.mjs(退出码 1)`,**一条 `↳` 自述都没有**。
读者看到的是"这条判据不成立",真相是"**它有多少条根本没测**"。
同一次运行里真红是带 `↳ 自报 88 > 登记 64` 自述的 —— **同一种红,两种含义**,
而 `red=10` 把两者混着报。

**★ 我没有照 pi 说的往 `CRASH_SIGNS` 里加 `ENOENT|EACCES|no such file`**:
第 329 行那条注释早把方向定死了 —— **"我不再往里加更多错误关键字:那是往文本解析里加补丁"**,
而且那个表**必然漏**(今天漏 ENOENT,明天漏 ENOTDIR/ELOOP…),加上去就是**第六次**修同一形状。

**改用结构事实**:`exitCode !== 0 && checks === null` ⇒ broken。
一个跑完的文件**必然**自报条数;"跑了但没自报"是另一条**单独记账**的情形(`unreported`)。
两者不会同时成立 ⇒ 只吃掉"崩了且毫无自报"那一格。
**要害是这个区分**:`exitCode !== 0` 本身**不足以**判 broken ——
不然真红会被一起吞掉,那是**假绿方向的错**,比误报成红更坏。
(实测 `build-stamp` 是真红且有 `# tests 7` ⇒ `checks=7≠null` ⇒ **不吃它** ✓。)

**变异验证**(撤掉 `cwd: ROOT`,模拟 pi 报的原状):
`files=29 ran=27 checks=348 red=8 broken=2 unreported=2` ⇒
那两条正确归 **broken**(`(退出码 1,且一条条数都没自报)`),而 **`red` 仍是 8 —— 没吃掉任何真红**。
还原逐字节一致(sha256 相同)。

## 四、★ 我自己在实现里踩的两个坑(都实测抓到)

1. **`abspath()` 用 `isAbsolute(p)` 判绝对路径 ⇒ 传 `URL` 对象抛 `ERR_INVALID_ARG_TYPE`。**
   `narrow-layout.test.mjs:8` 正是 `prose(new URL(p, import.meta.url))` ——
   一个**本来正确且与 cwd 无关**的调用,被我改崩了(两个 cwd 都崩,所以**看起来像"我修好了"**)。
   ⇒ 判据改成 `URL` 对象**原样透传**:它已是解析过的位置,转换才是引入 bug 的那一步。
   教训:**改一个共用入口时,要先把调用方的所有形态列全**,否则"修 A 破 B",而 B 看起来无关。
2. **`join` 忘了 import** ⇒ `ReferenceError`。补上。
   (正是 `run-all.mjs` 里那条 `CRASH_SIGNS` 注释讲的老故事。)

另外:我在 `run-all.mjs` 新注释里写了带括号的入口名,**被 `criteria-hygiene` 第 3 条抓到**
(它不剥注释)—— 这是我上一轮刚记下的坑的**第二次**,已改掉。

## 五、测量

| 相位 | RESULT(三种 cwd 一致)|
|---|---|
| build | `files=29 ran=29 checks=440 pass=438 fail=2 red=8 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=428 pass=427 fail=1 red=7 broken=0 unreported=0` |

重新接线后 `narrow-layout` 自报 **88** 条(登记 64)⇒ 它那条"登记数没跟上"的红变成**有自述**的了。
剩下 8 条红都不是本次改动(清一色并发会话的工作:`harmony-nav` 正在被改等)。
2026-09-17 19:00:11 +08:00

120 lines
5.6 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.

/**
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
*
* # 为什么需要它(2026-09-15 用户:「全面检查整体的动画」)
*
* 一次盘点查出两处**死动画**与一处**过宽的动画**,三种形态都极难靠肉眼发现:
* ① `@keyframes pane-in` 挂在 `html.view-switch .pane-enter` 上,而 **`.pane-enter`
* 没有任何组件在穿** —— 规则看着像"页面有入场动画",实际一次都不会播;
* ② `.animate-menu-in`(菜单入场)写好了、keyframes 也有,**同样没人穿**
* ⇒ 所有下拉/候选菜单其实都是"啪"地出现;
* ③ `html.view-switch .glass-control` 让**所有**控件档元素在每次切视图时一起动
* (几十个按钮/输入框同时淡入位移),是"闪"和卡顿的现成来源。
*
* 这三种都不是"动画不够好看",而是**接线断了**:类与使用者脱钩、作用域开得过大。
* 所以判据钉的是"接线",不是时长与曲线(那两样由人看着定)。
*
* # 判据
*
* 1. 每个 @keyframes 都必须有人穿 —— 从规则里取出穿它的类名,回源码里找;
* 找不到就是死动画(① 就是这么被抓住的)。
* 2. 弹层(.popup-surface 的菜单)必须带上菜单入场类(② 的接线)。
* 3. 不得再出现"整档控件一起动"的 view-switch 规则(③)。
* 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。
*/
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { check, finish } from './lib/checks.mjs';
import { code, PKG } from './lib/read.mjs';
// 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法,
// 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。
const css = code('src/index.css');
/** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */
function componentsSrc() {
const out = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = `${dir}/${e.name}`;
if (e.isDirectory()) walk(p);
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p));
}
};
/* ★ 基准用 PKG(= client/electron),**不是**相对 cwd:
* `readdirSync` 不经过 read.mjs,所以这里必须显式给基准。
* 原来传 'src' ⇒ 从仓库根直接跑这个文件时 `ENOENT: scandir 'src'`(pi 2026-09-17)。 */
walk(join(PKG, 'src'));
return out.join('\n');
}
const src = componentsSrc();
/** 取出所有 @keyframes 名 + 每条规则的 selector 文本 */
const keyframes = [...css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
const rules = [...css.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^{}]*)\}/g)].map(m => ({
sel: m[2].trim().replace(/\s+/g, ' '),
body: m[3]
}));
/**
* 一条 selector 在**最终**要不要靠某个类名才能命中?
* 只取"最后一个复合选择器"上的类(`html.view-switch .pane-enter` → `pane-enter`),
* 祖先里的类(html.view-switch、.app-shell)是状态开关,不算穿的人。
*/
function wearerClasses(sel) {
return sel
.split(',')
.map(part => part.trim().split(/\s+/).pop() || '')
.filter(last => last.startsWith('.'))
.map(last => last.split(/[\s.:[>]/)[0].replace(/^\./, ''))
.filter(Boolean);
}
const dead = [];
const unwrapped = [];
for (const k of keyframes) {
const users = rules.filter(r => new RegExp(`animation:\\s*${k}\\b`).test(r.body));
if (users.length === 0) {
dead.push(`${k}(没有任何规则用它)`);
continue;
}
// 这条 keyframes 的每一个使用者都必须"有人穿" —— 否则它还是不会播
const worn = users.some(r => {
const cls = wearerClasses(r.sel);
if (cls.length === 0) return true; // 元素选择器/通配:不算死
return cls.some(c => new RegExp(`["'\`\\s]${c}(?=["'\`\\s]|$)`).test(src) || src.includes(c));
});
if (!worn) unwrapped.push(`${k} ← ${users.map(u => u.sel).join(' | ')}`);
}
check(
'每个 @keyframes 都有人穿(没有死动画)',
dead.length === 0 && unwrapped.length === 0,
`只定义了没人穿的动画(看着有、永远不播):${[...dead, ...unwrapped].join(';')}`
);
check(
'弹层菜单带着菜单入场类',
/animate-menu-in/.test(rules.map(r => r.sel).join(',')) &&
/popup-surface[^`]*animate-menu-in|animate-menu-in[^`]*popup-surface/.test(src),
'菜单入场类存在但没人穿 —— 下拉/候选菜单是"啪"地出现(2026-09-15 盘点抓到的第二处死动画)'
);
// 只在**顶层规则**里查:reduced-motion 块里那句 `html.view-switch .glass-control` 是
// "把它关掉"的名单,不是"让它动"的规则 —— 第一版没区分,判据自己假红。
const topLevel = css.replace(/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
check(
'没有"整档控件一起动"的切视图规则',
!/html\.view-switch\s+\.glass-control/.test(topLevel),
'view-switch 又把 .glass-control 整档带上了 —— 每次切视图几十个按钮/输入框一起动(闪与卡顿的现成来源)'
);
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || [];
check(
'挂载即播那档被 reduced-motion 显式关掉',
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)),
'prefers-reduced-motion 覆盖不到 .rise-in —— 关掉动画的人照样会看到它'
);
finish('动画盘点');