Files
MailUI4Agents/client/electron/test/packaging.test.mjs
JianFeeeee 71a3c35610 按 pi 复核改五处:判据的"值/行为"原则、真正的构建门、typecheck 进门、Go 判据零匹配、迁移归属
pi 用反例与变异逐条点了五处,全部**先跑变异再改**(结论都写在原地)。

## 1 判据③:形状正则已删(它永远差一个反例)

实测 pi 的反例 —— `const k = PREFIX + accountId; return PREFIX;`(拼了但没返回)——
对第三版判据**仍然全绿**:第三版锚的是"**函数体里存在**这样的表达式",不是"**返回的**表达式"。
三版的骗法各一个(签名里的参数 / 提了一下没用 / 拼了没返回),都在判"源码里有没有那个形状",
而缺陷是"算出来的值对不对" ⇒ **权威交给行为判据**(`test/stores/background.test.ts` 直接断言
`storageKey('a') !== storageKey('b')`、`=== 'agentmail.background.acct-a'`、不退回全局键),
正则那条删掉并把反例写在原地(否则下一个人会好心加回来)。
保留"键必须来自 storageKey()"那条:那是**来源**约束,不是值对不对,正则在这里合适。
鸿蒙侧只能静态判(`.ets` 本机没有运行时),已把这条限制写进判据说明。
推广进规范:CRITERIA.md §6.7 + run-all 自检关键词(10 → 11)。

## 2 真正的门:`scripts/release-linux.sh`

- 实测 pi 提的变异:**`touch dist/index.html` 时 stamp 判据是绿的** —— 它抓不到"构建失败但碰过 dist"。
  stamp 是**探测器**(抓"src 改了产物没跟上"),门是**喂退出码**,两者不互替(§6.7.1)。
- `build:linux` 里**没有管道**(`&&` 链,退出码本来就传),但那次的哑巴失败是我在命令行手打
  `npm run build 2>&1 | tail -4 && …` 造成的;同时发现它跑的是**裸 `vite build`,跳过 `gen:bg`**。
- 于是把配方收成 `scripts/release-linux.sh`:`set -euo pipefail` + 走 `npm run build` + 再打包。
- 判据是**行为**的:注入失败的构建(`AGENTMAIL_BUILD_CMD='exit 7'`)→ 断言退出码非 0
  **且打包那步没跑**(标记文件不存在)。变异:脚本改成 `|| true` → 红 ✓。

## 3 typecheck 进 `npm test` 链

`npm run typecheck` 原本就有,但没人跑。先修掉它唯一的报错(我自己留下的未使用 import),现在干净;
`test` = run-all + vitest + typecheck。它恰好检查**没被任何测试 import 的文件**(vitest 只解析被测到的图)——
也就是那次 `is not exported by` 的形状。

## 4 Go 源码判据:零匹配 / 读不懂 都要红

改成三分:切不出函数体 → 红;字段在但值不是字面量 → 报「**判据读不懂**」(变异:`BgDim: defaultDim` → 红 ✓);
字段不在 → 红。静默放行是这类判据最危险的失败方式。
**措辞更正**:这条核对的是"与**这份服务端源码**的契约",不是"在跑的服务端二进制是 12/4"
(与"dist 是产物"同构);文档同步改。

## 5 迁移归属:定向做不到,就把"不可恢复"降级成"可恢复"

查实:`accountStore` 的 `activeId` 是**派生视图状态、不落盘**(`persist(accounts)` 只存数组),
所以**本机没有"上次活跃账号"标记可定向** —— 旧值的作者事后无法还原,定向迁移在原理上做不到。
升级前用 B、升级后先登录 A ⇒ A 接管 B 的外观,**一次性错档**,触发条件就这一条。
两件能做的都做了:**写了回读校验**(写不进去就不删旧键,避免净损失;变异:改成先删后写 → 2 条红 ✓)、
**删前另存** `agentmail.background.legacy.bak`(只写不读 ⇒ 不引入新的继承源,判据钉"只写不读")。
§7.12 把本地这半与显形方式写进同一行。

## 验证

`npm test` 退出码 0:14 个判据文件全绿 + vitest **265** 通过(+2 迁移行为测试)+ typecheck 干净;
`hvigorw assembleHap` BUILD SUCCESSFUL;安装包经新脚本重打(dist 与包同批)。
2026-09-14 15:41:52 +08:00

147 lines
7.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.

/**
* 桌面安装包的结构性断言(进 `npm test`,不需要浏览器)。
*
* # 为什么必须有一条这样的测试
*
* 这一条来自一次真实的白屏事故:`vite.config.ts` 没有设 `base`Vite 于是按
* 默认的 `/` 生成绝对路径 `src="/assets/index-xxx.js"`。Web 侧一切正常
* (网关在 `/` 下伺服),但 Electron 用 `loadFile()` 从
* **file:///…/dist/index.html** 加载同一份产物 —— 绝对路径会解析成
* `file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。
* 表现是「应用起来了、窗口标题对、`#root` 里一个节点都没有」:
* 页面全白,没有报错对话框,控制台里只有一条不起眼的资源加载失败。
*
* 而当时所有既有检查都是绿的:
*
* - `npm run build` 成功(它只管产物能不能生成)
* - deb/AppImage 结构检查通过BUILD.md 里那套元数据、chrome-sandbox 权限、
* asar 里有 dist —— **都只看文件在不在,不看它引用什么**
*
* 所以这里断言的是**产物内部的引用形态**,而不是「文件存在」。
*
* 另一条同样静默的风险:安装包里的 `dist/` 是构建时的快照。前端改了却没重打包,
* 装上去的人看到的是旧界面,而 Web 上是新的 —— 两边不一致但谁都不报错。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { rmSync } from 'node:fs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
const DIST_HTML = join(ROOT, 'dist', 'index.html');
test('★ vite 的 base 必须是相对路径(否则 Electron 白屏)', () => {
const cfg = readFileSync(join(ROOT, 'vite.config.ts'), 'utf8');
// 不能只看 `base:` 出现在注释里 —— 断言的是真的有一条 base 配置语句
assert.match(
cfg,
/^\s*base:\s*['"]\.\/['"]/m,
"vite.config.ts 必须写 base: './':同一份 dist 会被 Electron 从 file:// 加载," +
'绝对路径 /assets/... 在那儿解析不到,应用会白屏'
);
});
test('★ 构建产物里不能有绝对资源路径(这条能在没浏览器时抓住白屏)', () => {
if (!existsSync(DIST_HTML)) {
// 没构建过就跳过,但**要说出来**:静默跳过会让人以为验过了
console.log('dist/index.html 不存在 —— 先 cd client/electron && npm run build 才验得到)');
return;
}
const html = readFileSync(DIST_HTML, 'utf8');
const abs = [...html.matchAll(/(?:src|href)="(\/[^"]*)"/g)].map(m => m[1]);
assert.deepEqual(
abs,
[],
`产物里有绝对资源路径Electron 从 file:// 加载时会白屏:\n ${abs.join('\n ')}`
);
// 反向对照:相对引用必须真的在(否则「没有绝对路径」可能只是因为什么都没引用)
const rel = [...html.matchAll(/(?:src|href)="\.\/([^"]*)"/g)].map(m => m[1]);
assert.ok(rel.length >= 2, `产物应有多个相对资源引用,实际 ${rel.length}`);
assert.ok(
rel.some(p => p.endsWith('.js')) && rel.some(p => p.endsWith('.css')),
`相对引用里应同时含 js 与 css实际${rel.join(', ')}`
);
});
test('★ 安装包里的 dist 必须与当前构建一致(否则装上去的是旧界面)', () => {
// 「文件存在」不算 —— 要比**内容**。比的是每个资源的文件名Vite 带内容哈希),
// 所以只要前端产物变了,这里就会红。
const asar = join(ROOT, 'release', 'linux-unpacked', 'resources', 'app.asar');
if (!existsSync(asar) || !existsSync(DIST_HTML)) {
console.log('(没有安装包或没有 dist —— 打包前这条不适用)');
return;
}
const list = execFileSync('npx', ['asar', 'list', asar], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const wanted = [...readFileSync(DIST_HTML, 'utf8').matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]);
const missing = wanted.filter(p => !list.includes(`/dist/${p}`));
assert.deepEqual(
missing,
[],
'安装包里的 dist 与当前构建不一致(前端改了但没重打包):\n' +
` 缺:${missing.join(', ')}\n` +
' 重打npx electron-builder --linux -c.electronDownload.isVerifyChecksum=false'
);
});
/**
* ★ 发布脚本的**行为**判据:构建失败必须**真的停下**,而不是"看起来串起来了"。
*
* 为什么不是形状检查:这一族的病根是**退出码**。`npm run build 2>&1 | tail -4 && electron-builder`
* 里 pipeline 的退出码取最后一个命令(`tail`),于是构建失败、`&&` 照走、拿旧 dist 打了个新包,
* 而当时所有判据都绿。—— 断言"脚本里写了 pipefail"只证明写法像样,不证明它真的会停。
* 所以这里**注入一个失败的构建**,断言:①脚本退出码非 0②**打包那一步没有跑**。
* 接缝是 `scripts/release-linux.sh` 里的 AGENTMAIL_BUILD_CMD / AGENTMAIL_PACK_CMD。
*/
test('★ 发布脚本:构建失败时不打包(注入失败的构建,看它是否真的停下)', () => {
const script = join(HERE, '..', 'scripts', 'release-linux.sh');
assert.ok(existsSync(script), '发布脚本要在(它是"构建成功才打包"那道门)');
const marker = join(HERE, '..', '.tmp', `pack-would-run-${process.pid}.txt`);
try { rmSync(marker, { force: true }); } catch { /* 不存在就算了 */ }
const r = spawnSync(
'bash',
[script],
{
cwd: join(HERE, '..'),
encoding: 'utf8',
// 构建:注定失败;打包:若被执行就留下标记文件
env: {
...process.env,
AGENTMAIL_BUILD_CMD: 'exit 7',
AGENTMAIL_PACK_CMD: `printf ran > ${JSON.stringify(marker)}`
}
}
);
const out = `${r.stdout || ''}${r.stderr || ''}`;
assert.notEqual(r.status, 0,
`构建失败时脚本必须非 0 退出(实际 ${r.status})—— 否则调用方会以为发好了。输出:\n${out}`);
assert.ok(!existsSync(marker),
'构建失败后**不许进入打包**(打包器会拿旧的 dist 打出"看起来新"的包)。输出:\n' + out);
assert.match(out, /构建/, '输出要能看出停在哪一步');
assert.ok(!/完成dist 与安装包同批/.test(out), '失败路径不该报"完成"');
});
test('★ 发布脚本:构建只有一条路(走 npm run build不能是裸 vite build', () => {
const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
const scriptPath = join(HERE, '..', 'scripts', 'release-linux.sh');
const script = readFileSync(scriptPath, 'utf8');
/*
* 两个真实的坑,各自被这条挡住:
* ① `build:linux` 原来直接跑裸 `vite build` —— 那会**跳过 `gen:bg`**
* (背景接管用的 CSS 生成步骤),生成物缺失/过期时打包器照打不误;
* ② 打包配方只写在 package.json 的脚本串里,改一处忘一处。
* 现在 `build:linux` 只负责调用脚本,脚本里走 `npm run build`= gen:bg + vite build
*/
assert.match(pkg.scripts['build:linux'], /release-linux\.sh/, 'build:linux 要调用这个脚本(配方只留一处)');
assert.match(script, /AGENTMAIL_BUILD_CMD:-npm run build\}/, '构建要走 `npm run build`(带上 gen:bg');
assert.ok(!/^\s*(npm run build\s*\|)/m.test(script), '脚本里不许把构建接进管道(管道会换掉退出码)');
assert.match(script, /set -euo pipefail/, '要有 pipefail万一以后有人加了管道退出码仍是构建的');
});