Files
MailUI4Agents/client/electron/test/build-stamp.test.mjs
JianFeeeee 9d40816230 docs(判据): ★★ 「重构建」是**两步** —— 只做第一步,红会从 build-stamp 搬到 packaging
**实测(2026-10-03,每步退出码取自不接管道的运行)**:
    提交 ⇒ HEAD 前进 ⇒ build-stamp 红(产物记旧 rev)
    ① npm run build                      ⇒ build-stamp 绿、**packaging 红**
    ② npx electron-builder --linux …    ⇒ 两条同时绿
★ 只做①的人会以为「修好了」(前一条确实绿了),而红只是**换了个位置**。

**为什么①会让 packaging 转红**:packaging 比的是「asar 里的 dist 文件名」
vs「当前 dist/index.html 引用的文件名」,而 Vite 输出带**内容哈希**
⇒ 重构建一换文件名,asar 立刻过期。两条判据是**同一条链的两环**,
而 package.json 里**没有** beforeBuild 钩子把两者串起来 ⇒ 必须人记得做两次。

**改了三个地方,因为「报错文案」比文档更容易被看到(§14 同一条道理)**:
· build-stamp 的报错文案**自带第二步**。原文只写「正确修法只有一个:重跑构建」——
  实测证明那句话**只完成了一半**,而只看文案的人会照做然后以为好了。
  已用变异测试确认:把产物 gitRev 改成 0000000 ⇒ 变红且两条步骤都出现,
  改回 ⇒ 绿。
· CRITERIA.md 新增 §6.0.6「同一个红会自己搬家时,要把它变成有界的」。
  它讲的是**读数的信噪比**:一条结构性长期红与真缺陷**同屏**,
  会把整屏红的价值抵消("看到了 ⇒ 当没看见")。
  明确**不能**靠加跳过名单解决(那是把它变成看不见),
  正解是到期条件可机检 + 报错文案自带下一步。
· DEBTS.json 的 build-stamp-stale-artifact-blocks-verification:
  把 due/where/note 补上「两步」的实测事实,并把**第二环 packaging**
  补进 where —— 原笔只记了 build-stamp 那一环。

**边界 / 未做**
· 没有把两步串进 npm 脚本或 beforeBuild —— 那是**构建流程**的改动,
  而本工作区正被多个会话并发使用;由人决定。
· 这笔债**仍未结算**:它会在**下一次提交**时重现(结构性的,与代码无关)。
2026-10-03 11:17:18 +08:00

172 lines
10 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-14)。
*
* # 为什么
*
* 用户连续三轮说「webui 还没改」,而我每次都能证明部署是活的:入口页 `no-cache`、
* 资源哈希 immutable、线上 bundle 与本地构建逐字节一致……但**隔着屏幕谁也说不清
* 对方的浏览器跑的是哪一份**。这行字把这件事变成可核对的:它在界面设置里显示
* `git短哈希·月日-时分`,刷新后数字变了就是拿到了新构建。
*
* 判据落在三处接线:vite 注入 → 组件渲染 → 产物里真的有。
*/
import { prose } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import { srcState } from '../scripts/build-info.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const read = p => prose(join(HERE, p));
const STAMP = /[0-9a-f]{7,}·\d{4}-\d{4}/;
test('vite 注入 __BUILD_STAMP__', () => {
const cfg = read('../vite.config.ts');
assert.match(cfg, /__BUILD_STAMP__/);
assert.match(cfg, /git rev-parse --short HEAD/, '戳里要有 git 短哈希');
});
test('界面渲染这个戳', () => {
const cmp = read('../src/components/BackgroundPicker.tsx');
assert.match(cmp, /__BUILD_STAMP__/, '组件必须渲染它,而不是只定义');
assert.match(cmp, /界面构建/, '文案里要能认出来');
});
test('★ 构建产物里真的带着戳', () => {
const dir = join(HERE, '../dist/assets');
const js = readdirSync(dir).filter(f => f.startsWith('index-') && f.endsWith('.js'));
assert.ok(js.length > 0, '先跑 npm run build');
const found = js.some(f => STAMP.test(prose(join(dir, f))));
assert.ok(found, '产物里没有构建戳 —— 界面上就永远看不出自己跑的是哪一份');
});
test('判据自检:这个正则不能匹配随便一段文本', () => {
assert.equal(STAMP.test('界面构建 index-abcdef.js'), false);
assert.equal(STAMP.test('d2904fc·0914-0910'), true);
});
/*
* 仓库里不得跟踪缓存/构建产物(`.tmp/` 那次事故的判据化,pi 提议)。
*
* 2026-09-14:一次 `git add -A` 把 `.tmp/node-compile-cache/**` 与 `.tmp/studtmp-*`
* (554 个文件、2.6MB)提交进了版本库。靠"记得先看 git status"防不住下一次 ——
* 而这件事**不报错、不报警**,代价却落在别人身上:复核者读 diff 判断"改了什么",
* 554 个缓存文件直接把改动面埋了(同一工作区里还有并发写入者,这尤其致命)。
* 所以按 pi 的建议,改成一条结构性判据。
*/
test('★ 版本库里不得跟踪缓存/构建产物(.tmp、node_modules、dist、release、embed 产物)', () => {
const tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8', cwd: ROOT })
.split('\n')
.filter(Boolean);
assert.ok(tracked.length > 100, `git ls-files 只列出 ${tracked.length} 个文件,判据大概跑错目录了`);
/*
* `server/internal/static/static/` 是 go:embed 的落点:**它的存在**靠一个
* 有意跟踪的 `placeholder.html` 撑着(.gitignore 里也是 `*` + `!placeholder.html`)——
* 那是设计,不是产物。所以这条只放行那一个名字,其余一律算产物。
*/
const EMBED_PLACEHOLDER = 'server/internal/static/static/placeholder.html';
const poison = tracked.filter(f =>
/^(\.tmp\/|.*\/node_modules\/|client\/electron\/dist\/|client\/electron\/release\/|release\/|server\/internal\/static\/static\/)/.test(f)
&& f !== EMBED_PLACEHOLDER
);
assert.deepEqual(
poison.slice(0, 10),
[],
`版本库里跟踪了缓存/构建产物(共 ${poison.length} 个,前 10 个如下)—— 它们会让复核者看不清真实改动面:\n ${poison.slice(0, 10).join('\n ')}`
);
// 反向对照:判据要真能认出这类路径(否则正则写错也是一片绿)
const looksTracked = p => /^(\.tmp\/|.*\/node_modules\/|client\/electron\/dist\/|client\/electron\/release\/|release\/|server\/internal\/static\/static\/)/.test(p);
assert.ok(looksTracked('.tmp/node-compile-cache/v22/0014c7b4'), '自检:认不出 .tmp 下的缓存');
assert.ok(looksTracked('client/electron/release/agentmail-web_0.1.0_amd64.deb'), '自检:认不出安装包产物');
assert.ok(!looksTracked('client/electron/src/index.css'), '自检:把源码当成产物了');
// 那个占位文件必须还在:它是 go:embed 落点存在的唯一理由,删了 Go 侧就编不过
assert.ok(tracked.includes(EMBED_PLACEHOLDER), 'go:embed 的占位文件丢了 —— 没有它 static/ 目录不存在,Go 侧编不过');
});
test('★ 产物必须自报来源:BUILD_INFO 精确比对(不是比时间戳)', () => {
/*
* 这条是踩出来的(2026-09-14 接手 pi 的 WebUI 开项时):
* 我把 `npm run build` 串在管道里(`npm run build 2>&1 | tail -4 && electron-builder …`),
* **构建失败被管道吞了**(pipeline 的退出码是 tail 的),于是 electron-builder
* 拿旧的 dist 打了一个新包 —— 而所有判据都是绿的:
* - vitest 绿:测试运行时对"缺的具名导出"很宽容(拿到 undefined),只有打包器会报;
* - packaging 绿:它比的是"dist vs 安装包",两边都是旧的,自然一致。
*
* 第一版判据比的是**时间戳**(dist 不早于 src 最新文件),它能抓住那次事故,
* 但 pi 2026-09-14 指出它是**代理变量**,两层都靠不住:
* ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿 —— 那个坑由
* scripts/release-linux.sh 喂退出码来堵,见 CRITERIA.md §6.7.1);
* ② **dist 比 src 新也不等于 dist 是从这份 src 构建的**(`git checkout`、`cp`、
* 时钟都能骗过 mtime —— 我确实用 checkout 造过一次假红)。
* 现在改成**内容自证**:`dist/BUILD_INFO.json` 记下构建时的 srcHash 与 gitRev,
* 判据重算一遍当前的指纹再**精确比对**。于是判据说的是"产物记的源状态 == 当前源状态",
* 而不是"谁的时间更晚" —— "代理"两个字没有了。
*/
const infoPath = join(ROOT, 'client/electron/dist/BUILD_INFO.json');
const distIndex = join(ROOT, 'client/electron/dist/index.html');
if (!existsSync(distIndex)) {
console.log('(没有 dist —— 先 npm run build 才验得到这条)');
return;
}
assert.ok(existsSync(infoPath),
'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' +
' 重构建:cd client/electron && npm run build');
const info = JSON.parse(prose(infoPath));
const now = srcState();
// 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪
assert.equal(info.srcHash, now.srcHash,
`产物与当前源码不是同一份(改了没重新构建):\n` +
` 产物记录的 srcHash:${String(info.srcHash).slice(0, 12)}(${info.srcFiles} 个文件)\n` +
` 当前源码的 srcHash:${now.srcHash.slice(0, 12)}(${now.files} 个文件)\n` +
` 重构建:cd client/electron && npm run build(注意别把它的退出码丢在管道里)`);
if (now.gitRev && info.gitRev) {
assert.equal(info.gitRev, now.gitRev,
`产物是在另一个提交上构建的(产物记 ${info.gitRev},当前 HEAD ${now.gitRev})—— 重构建再复核。` +
`\n**别去改 BUILD_INFO.json 里的 gitRev / srcHash 了事** —— 那是把这条判据废掉` +
`(§14 说的「最常见的错误修法」):产物自证的意义就是「产物必须来自当前源码」,` +
`改记录只会让两者不一致却看不出来。正确修法只有一个:重跑构建。` +
`\n\n★★★ 而重跑构建是**两步**,不是一步(2026-10-03 实测,` +
`\n改完先看 packaging 那条,别以为修好了):` +
`\n ① cd client/electron && npm run build` +
`\n ② npx electron-builder --linux -c.electronDownload.isVerifyChecksum=false` +
`\n**为什么必须两步**:\`packaging\` 比的是「asar 里的 dist 文件名」vs` +
`\n「当前 dist/index.html 引用的文件名」,而 Vite 输出带**内容哈希** ——` +
`\n重构建一换文件名,asar 就过期 ⇒ 本条转绿的同时 \`packaging\` 会转红。` +
`\n只做第①步的人会以为「修好了」(前一条绿了),而红只是**换了个位置**。` +
`\n形状与实测记在 \`CRITERIA.md\` §6.0.6;` +
`\n那笔账在 docs/DEBTS.json 的 build-stamp-stale-artifact-blocks-verification。`);
}
/*
* `gitDirty` 只**展示**不判定:共享工作区常年是脏的(并发写作者的未提交改动),
* 拿它当红/绿依据会让这条判据天天误报。它写在 BUILD_INFO 里是为了复核时先看这一行
* —— "这个包对应的源码状态干净吗"(pi 提的"一条红自报来源"的构建侧同款)。
*/
assert.ok(typeof info.gitDirty === 'boolean', 'BUILD_INFO 要记录构建时工作树是否干净(供复核者判断)');
assert.ok(info.builtAt && info.buildCmd !== undefined, 'BUILD_INFO 要带构建时间与构建命令');
});
/**
* ★ 脏树产物**不得自称发布候选**(pi 2026-09-14 §1:把 gitDirty 从展示升成标签)。
*
* 共享树上永远有别人在写文件,"脏"是常态 —— 所以这条不判"脏就该红"(那会天天假红),
* 判的是**两者必须可区分**:脏树产物只能自用,不能给人装;而这句话得由产物自己说,
* 不能靠读它的人去猜。
*/
test('★ 脏树产物不得自称发布候选(标签与 gitDirty 必须一致)', () => {
const infoPath = join(ROOT, 'client/electron/dist/BUILD_INFO.json');
const info = JSON.parse(prose(infoPath)); // JSON 是数据 → prose
assert.equal(typeof info.gitDirty, 'boolean', 'BUILD_INFO 必须报 gitDirty');
assert.equal(typeof info.releaseCandidate, 'boolean',
'BUILD_INFO 必须报 releaseCandidate(标签,不是展示)');
assert.equal(info.releaseCandidate, !info.gitDirty,
`发布候选标签与工作树状态矛盾:gitDirty=${info.gitDirty} 却说 releaseCandidate=${info.releaseCandidate}。\n` +
'脏树打的包里可能含着别人**未提交**的半成品 —— 它只能自用,必须在门禁上可区分。');
});