diff --git a/client/electron/test/animation-audit.test.mjs b/client/electron/test/animation-audit.test.mjs index a3335a1..1128f82 100644 --- a/client/electron/test/animation-audit.test.mjs +++ b/client/electron/test/animation-audit.test.mjs @@ -23,8 +23,9 @@ * 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 } from './lib/read.mjs'; +import { code, PKG } from './lib/read.mjs'; // 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法, // 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。 @@ -40,7 +41,10 @@ function componentsSrc() { else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p)); } }; - walk('src'); + /* ★ 基准用 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(); diff --git a/client/electron/test/lib/read.mjs b/client/electron/test/lib/read.mjs index b39d3ea..3292258 100644 --- a/client/electron/test/lib/read.mjs +++ b/client/electron/test/lib/read.mjs @@ -17,6 +17,58 @@ * "解释性注释与它解释的标识符同名"的地方)。 */ import { readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/* + * ★★ **相对路径一律相对 `client/electron` 解析**,与调用者的 cwd 无关 + * (pi 2026-09-17 实测报的洞;这是**第二道**保险)。 + * + * 为什么要有这一层:本目录里有 12 处形如 `code('src/index.css')` 的调用 —— + * 它们**假定包目录是基准**,只是没人把那个假定钉住。于是 + * `node test/narrow-layout.test.mjs`(cwd=仓库根)⇒ `ENOENT: open 'src/index.css'` + * 崩在 import 期。 + * + * ★ 运行器那层已经钉了 `cwd: ROOT`(`run-all.mjs` 的 `spawnSync`),但**它盖不住 + * "直接跑单个判据文件"这个调用法** —— 而 pi 报的复现步骤正是直接跑单文件。 + * 两层各管一段,不是重复: + * · 运行器那层:覆盖现有的与将来的**所有**判据(含不走 `read.mjs` 的读取); + * · 这一层:让**本入口**在**任何 cwd** 下语义一致(直接跑单文件也成立)。 + * + * ⚠️ 基准取**本文件的上一级**(`test/lib` → `test` → `client/electron`), + * 而不是 `process.cwd()`:判据目录与包目录的相对位置是**代码里的结构事实**, + * cwd 是**调用者的临时状态**。用后者正是上面那个 bug 的成因。 + * 绝对路径原样透传(不碰),于是仓库根/绝对路径的调用不受影响。 + */ +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +/* + * ★ 必须**同时接受 `URL` 对象**(2026-09-17;这是我第一版改坏的地方)。 + * + * 我用 `isAbsolute(p)` 判"是不是绝对路径",而 `isAbsolute` **只吃字符串** —— + * 传 `URL` 就抛 `ERR_INVALID_ARG_TYPE`。`narrow-layout.test.mjs:8` 正是这么调用的: + * const read = p => prose(new URL(p, import.meta.url)); + * 于是我把一个**本来正确、且与 cwd 无关**的调用改崩了。 + * + * ⇒ 判据:`URL` 对象**原样透传**。它已经是一个**解析过的绝对位置**, + * 语义上比字符串更明确,没有任何理由去动它 —— 转换反而是引入 bug 的那一步。 + * (这条也是本仓的老形状:**改一个共用入口时,要先把调用方的所有形态列全**, + * 否则"修 A 破 B",而 B 那处看起来完全无关。) + */ +const abspath = (p) => { + if (p instanceof URL) return p; // 已解析的位置,原样透传 + return isAbsolute(p) ? p : join(PKG_ROOT, p); +}; + +/* + * 把基准**导出去**:判据里凡是要 `readdirSync`/`existsSync`/`import()` 的地方 + * (这些不经过本文件)必须用**同一个**基准。 + * 否则"有些路径相对包目录、有些相对 cwd"这个洞会以别的形式回来 —— + * 而每处各自拼一次 `new URL(…, import.meta.url)` 就是"同一个事实多份实现", + * 那正是本仓反复消的形状(`stripStrings` 曾有两份兄弟副本、`blurStyleFor` 一份死的)。 + */ +export const PKG = PKG_ROOT; +/** 把包内相对路径解析成绝对路径(与 code/prose/bytes 用的**同一个**函数) */ +export const pkgPath = abspath; /** * 剥掉注释:只用于"代码里有什么"。 @@ -37,7 +89,7 @@ export function stripComments(src) { /** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */ export function code(path) { - return stripComments(readFileSync(path, 'utf8')); + return stripComments(readFileSync(abspath(path), 'utf8')); } /** @@ -95,10 +147,10 @@ export function stripStrings(src) { /** 读文件**原文** —— 判"注释/文档里写了什么"用这个 */ export function prose(path) { - return readFileSync(path, 'utf8'); + return readFileSync(abspath(path), 'utf8'); } /** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */ export function bytes(path) { - return readFileSync(path); + return readFileSync(abspath(path)); } diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 595101f..309bdec 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -301,8 +301,31 @@ 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' }); + /* + * 收集输出再自己打回去:观感不变(stdio:'inherit' 的等价物),但能顺手做"跑了吗"的检查 + * + * ★★ `cwd: ROOT` 这一格是**读数的一部分**,不是顺手加的(pi 2026-09-17 实测)。 + * + * 原来这里不传 `cwd` ⇒ 子进程**继承调用者的 cwd**,于是**同一份代码、两种调用法、两个结论**: + * + * cd client/electron && node test/run-all.mjs ⇒ 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。真因:有两条判据按**包内相对路径**读文件 + * (用 read.mjs 那个剥注释的入口、传包内相对路径那种写法),它们本来就假定 + * `cwd = client/electron`, + * 只是没人把那个假定钉住 ⇒ 从仓库根跑时 `ENOENT` 崩在 import 期。 + * + * ⇒ **要让运行器去满足判据的既有假定,不是改判据去迁就调用者**: + * `ROOT` 已经是判据文件的绝对路径,这里再钉 `cwd` 就是让"包内相对路径"这个 + * 既有约定**真的成立**。一行覆盖现有的与将来的所有判据。 + * (判据自己按 `HERE` 解析路径是**第二件事**、第二道保险,不替代这一行。) + * + * ★ 这条比"套件整个死掉"隐蔽得多:死掉时输出只剩一行、谁都看得出来; + * 而这里是**看起来很正常的汇总行**,只有把两种调用法的数字并排放才看得出。 + * **"同一份代码两种调用法两个结论"本身就是读数不可信** —— 所以这不是"调用者姿势不对"。 + */ + const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8', cwd: ROOT }); const out = (r.stdout || '') + (r.stderr || ''); process.stdout.write(r.stdout || ''); process.stderr.write(r.stderr || ''); @@ -334,8 +357,7 @@ for (const [file, flags, expected] of SUITE) { 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; - /* + const empty = out.trim().length === 0; /* * ── 条数累计:**必须在红/绿分支之外** ── * * 原来它写在下面 `else { … }`(只在**绿**时进入)里,于是**红文件一条都不计入**, @@ -394,13 +416,40 @@ for (const [file, flags, expected] of SUITE) { fail: fileChecks === null ? null : fileFail, exitCode: r.status, }); + /* + * ★★ 结构判据补一格:**退出码非零 且 一条条数都没自报** ⇒ broken(不是 red)。 + * + * 这是 pi 2026-09-17 报的第二个洞,而它比 cwd 那条更毒 —— 它是**分类错了**: + * `narrow-layout` 在 import 期 `ENOENT` 崩掉(**一条 `not ok` 都没有**), + * 于是走 `CRASH_SIGNS` 那条兜底;而 ENOENT **不在**那个关键字表里 + * ⇒ `crashed=false` ⇒ 落进 `reds`:`- test/narrow-layout.test.mjs(退出码 1)`。 + * 读者看到的是"**这条判据不成立**",真相是"**这个文件没读完就死了,它有多少条根本没测**"。 + * 同一次运行里真红的样子是带 `↳ 自报 N 条 > 登记 M 条 …` 自述的 —— + * **同一种红,两种含义**,而 `red=10` 这个总数把两者混在一起报。 + * + * ★ 为什么不照 pi 说的往 `CRASH_SIGNS` 里加 `ENOENT|EACCES|no such file`: + * 上面第 329 行那条注释早就把方向定死了 —— **"我不再往里加更多错误关键字: + * 那是往文本解析里加补丁,方向是错的"**,而且那个表**必然漏**(今天漏 ENOENT, + * 明天漏 ENOTDIR/ELOOP/ERR_DLOPEN_FAILED…)。照加上去就是**第六次**修同一个形状。 + * + * ⇒ 改用**结构**事实:这条判据是不是**跑完过**?不是就看它有没有留下**任何**条数记录。 + * 一个跑完的文件必然自报条数(`RESULT pass=` 或 `# tests`);**跑了但没自报**是 + * 另一条已知的、单独记账的情形(`unreported`,见下面 `else` 分支)。 + * 两者**不会同时成立** ⇒ 这条规则只吃掉"崩了且毫无自报"那一格,不碰任何真红: + * 断言失败的文件 `exited != 0` 但**有**自报条数 ⇒ 仍然走 red。 + * (★ 这个区分才是要害:`exitCode !== 0` 本身**不足以**判 broken —— + * 不然真红会被一起吞掉,那是**假绿方向的错**,比误报成红更坏。) + */ + const diedWithoutReporting = r.status !== 0 && fileChecks === null; // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 - if (crashed || empty) { + if (crashed || empty || diedWithoutReporting) { const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160); - brokens.push(`${file}(${empty ? '跑完没有任何输出' : `退出码 ${r.status},但没有一句断言失败`})` + + brokens.push(`${file}(${empty ? '跑完没有任何输出' + : diedWithoutReporting && !crashed ? `退出码 ${r.status},且一条条数都没自报` : `退出码 ${r.status},但没有一句断言失败`})` + (firstErr ? `\n ↳ ${firstErr}` : '') + '\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' + - '\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过。' + + '\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过、' + + '\n **按相对路径读文件但 cwd 不对**(`ENOENT: … open \'src/index.css\'`)。' + '\n 用于变体验证时:broken **不算这次变异有效**,要重做。'); } else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`); else {