Files
MailUI4Agents/client/electron/test/packaging.test.mjs
JianFeeeee 9af4fde706 docs(计划): P2a/P2b 归属入册(鸿蒙侧,参照物是 WebUI 已成的形态);产物自证两条报错补"最常见的错误修法"
1. **归属弄反了**(pi 2026-09-14):联系人卡片视图 / 收件箱按会话折叠 / 删平级 tab
   **全在鸿蒙侧落地**;WebUI 的 `ContactPanel.tsx`(列表/卡片两视图)、`lib/mailGroups.ts`
   (按会话折叠)都是**参照物**,WebUI 根本没有平级会话入口。已写进计划文档,
   并写明「收件箱只取 50 封」那件事**不与 P2a 互相阻塞**(它是 WebUI 现存缺陷,单独查)。

2. **§14 落到产物自证这一族**:`build-stamp` / `packaging` 的报错原先只有「正确修法」
   (重构建 / 重打包),补上**最常见的错误修法** —— 别改 `BUILD_INFO.json` 的
   `gitRev`/`srcHash`、别改断言或塞一份手抄 dist:那是把判据废掉。
2026-09-14 17:33:05 +08:00

150 lines
7.8 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 { code, prose } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { 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 = code(join(ROOT, 'vite.config.ts'));
// 不能只看 `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 = prose(DIST_HTML);
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 = [...prose(DIST_HTML).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\n' +
' **别去改这条断言、也别往包里塞一份手抄的 dist 了事** —— 那是把判据废掉\n' +
' (§14 说的「最常见的错误修法」):包里的 dist 必须就是当前构建的产物。'
);
});
/**
* ★ 发布脚本的**行为**判据:构建失败必须**真的停下**,而不是"看起来串起来了"。
*
* 为什么不是形状检查:这一族的病根是**退出码**。`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(prose(join(HERE, '..', 'package.json')));
const scriptPath = join(HERE, '..', 'scripts', 'release-linux.sh');
const script = prose(scriptPath);
/*
* 两个真实的坑,各自被这条挡住:
* ① `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:万一以后有人加了管道,退出码仍是构建的');
});