Files
MailUI4Agents/client/electron/test/build-stamp.test.mjs
JianFeeeee dbdb2f5039 跨端: 修打包坑(构建失败被管道吞掉)+ dist 新鲜度判据;判据从"窗口式"改成同表达式
接手 pi 的 WebUI 开项途中踩到三个坑,都已修好并配判据(承接 e94313f)。

## 坑 1:`npm run build 2>&1 | tail -4 && electron-builder …` 吞掉构建失败

pipeline 的退出码是 `tail` 的 → 构建失败、`&&` 照样往下走、electron-builder
**拿旧的 dist 打了新包**。而所有判据都是绿的:
- vitest 绿(见坑 2);packaging 绿(它比 dist vs 安装包,两边都是旧的,自然一致)。

新增判据:**dist 必须比 src 新**(build-stamp)。变异:`touch` 一个 src 文件 → 红;
重新 `npm run build` → 绿。错误信息写明"注意别把它的退出码丢在管道里"。
已重构建 + 重打包(deb 与 app.asar 同批,15:19)。

## 坑 2:测试通过 ≠ 能打包

真正的失败:`AGGREGATE_ID` / `isUsableAccount` 被我当成 `accountStore` 的导出
(它们住在 `lib/accounts.ts`)。vitest 263 条全绿,生产构建直接报
`"AGGREGATE_ID" is not exported by "src/stores/accountStore.ts"` ——
测试运行时对缺的具名导出是宽容的(拿到 undefined)。**生产构建是一道独立的门。**

## 坑 3:判据写成"窗口式",被自己的变异测试抓住两次

`appearance-defaults` 的"取键函数必须把账号拼进去":
1. 第一版从 `export function` 切到 `}` → **参数表里的 accountId 满足了正则**,
   把实现退回全局键仍然全绿(假判据!);
2. 第二版只取函数体 → `return accountId ? PREFIX : PREFIX;`(提了一下没用)又骗过去;
3. 第三版要求**同一个表达式里既有常量又有账号的插值/拼接**:
   两种退化都红,合法的 `PREFIX + accountId` 写法仍然绿。

三种变异都验过(红/红/绿),还原后基线绿。

## 文档

§7.12 两行改为「已修」并写明依据(默认值=服务端契约;缓存键差异已消除,旧全局键
只作一次性迁移源);新增 §7.20 记这三个坑与推论(产物是 gitignore 的,
"源码修好"≠"用户手上那个包修好")。

## 验证

`npm test` 退出码 0(13 个判据文件全绿 + vitest 263 passed);
`hvigorw assembleHap` BUILD SUCCESSFUL;`npm run build` + electron-builder 均 exit 0。
2026-09-14 15:20:30 +08:00

130 lines
6.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.

/**
* 界面必须带一个可核对的构建戳2026-09-14
*
* # 为什么
*
* 用户连续三轮说「webui 还没改」,而我每次都能证明部署是活的:入口页 `no-cache`、
* 资源哈希 immutable、线上 bundle 与本地构建逐字节一致……但**隔着屏幕谁也说不清
* 对方的浏览器跑的是哪一份**。这行字把这件事变成可核对的:它在界面设置里显示
* `git短哈希·月日-时分`,刷新后数字变了就是拿到了新构建。
*
* 判据落在三处接线vite 注入 → 组件渲染 → 产物里真的有。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } 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, '..', '..', '..');
const read = p => readFileSync(join(HERE, p), 'utf8');
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(readFileSync(join(dir, f), 'utf8')));
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('★ dist 必须比 src 新(前端改了没重新构建 —— 套件照样全绿,只有这道门会红)', () => {
/*
* 这条是踩出来的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 下最新的文件。
*/
const distIndex = join(ROOT, 'client/electron/dist/index.html');
if (!existsSync(distIndex)) {
console.log('(没有 dist —— 先 npm run build 才验得到这条)');
return;
}
const distAt = statSync(distIndex).mtimeMs;
// 构建过程自己会写 src/background-takeover.generated.css在 dist 之前),不算"源码改动"
const newest = { at: 0, file: '' };
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name !== 'background-takeover.generated.css') {
const at = statSync(p).mtimeMs;
if (at > newest.at) { newest.at = at; newest.file = p; }
}
}
};
walk(join(ROOT, 'client/electron/src'));
assert.ok(newest.file, 'src 下应当有文件');
assert.ok(
distAt >= newest.at,
`前端源码比 dist 新(改了没重新构建):\n` +
` src 最新:${newest.file.replace(ROOT + '/', '')}\n` +
` dist${distIndex.replace(ROOT + '/', '')}\n` +
` 重构建cd client/electron && npm run build注意别把它的退出码丢在管道里`
);
});