Files
MailUI4Agents/client/electron/test/packaging.test.mjs
JianFeeeee 8b2206ed53 fix(electron): Phase 3 验收抓到的两个静默缺陷 —— 白屏与登录
Phase 3(写信 + 附件 + 权限面板)的验收脚本第一次跑就把这两件事翻出来了,
两个都**表现正常**:进程活着、窗口标题对、接口能通,只有结果不对。

## 1. 打包后的应用是白屏(vite 的 base 缺省值)

`vite.config.ts` 没设 `base`,Vite 按默认的 `/` 生成 `src="/assets/index-xxx.js"`。
同一份 dist 有两个宿主:网关在 `/` 下伺服它(Web 正常),Electron 用 `loadFile()`
从 **file:///…/dist/index.html** 加载它 —— 绝对路径在那儿解析成
`file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。

现场:`#root` 里一个子节点都没有。没有报错对话框,控制台里只有一条不起眼的
资源加载失败。而当时所有既有检查都是绿的:`npm run build` 成功、deb 元数据检查、
asar 内容清点(**它们只看文件在不在,不看文件引用什么**)。

修法:`base: './'` —— 两边都对(Web 在 /index.html 里 `./assets/x.js` → `/assets/x.js`;
Electron 在 dist/index.html 里 → `dist/assets/x.js`)。

## 2. 桌面壳用账号密码登录是断的,而且静默失败

账号密码登录靠 `SameSite=Lax` 的会话 Cookie,而桌面壳的页面是 `file://`
(**不透明源**)—— Chromium 按第三方上下文处理它,Cookie **不予存储**。

实测现场:`POST /auth/login` 返 **200**、响应体能读出用户名,但 `document.cookie`
是空的,紧接着的 `/auth/me` 返 **401**;界面停在登录页,看起来像「密码错了」,
而同样的账号密码用 curl 登录是成功的。所以这不是凭据问题。

修法:桌面壳里**不再给账号密码表**(一个必然失败的按钮比没有更糟),改成粘贴
**用户密钥**(`Authorization: Bearer`,桌面端本来就该这么用):
- preload 显式声明 `__AGENTMAIL_SHELL__ = 'desktop'`(宿主契约,而不是让渲染层
  sniff 协议;顺带让 jsdom 里可测 —— 那里的 `location.protocol` 不可重写)
- 新增 `authStore.loginWithKey`:成功后才留下令牌,失败**还原**(否则之后每个请求
  都会带上这个坏 key 并 401,而人看到的是「重输一次也不行」)
- 顺手修了 label 与 input 没有关联(`htmlFor`/`id`)—— 无障碍缺陷,也让测试能按标签查

## 验收

- 结构性守卫进 `npm test`(`test/packaging.test.mjs`,不需要浏览器):base 必须是
  相对路径、产物里不能有绝对资源引用、**安装包里的 dist 与当前构建一致**
  (前端改了没重打包时,装上去的人看到的是旧界面,两边不一致却谁都不报错)。
  判据自检过:把 base 改回 `/` 或把产物改回绝对路径,各自都能让对应那条变红。
- 组件测试 6 条(两种壳的形态、密钥成功/失败、空密钥不可提交)。
- `test/manual/desktop-phase3-verify.mjs`:真起打包好的应用(xvfb + CDP),
  一条贯穿的链 —— 用桌面 UI 写信带附件 → 外部核验信与附件真到了网关 →
  这封信触发 zcode 的真实授权请求 → 在桌面**授权面板**里点同意 →
  外部核验 **Agent 真的执行了**(标记文件出现)。第二次跑 14/14 全绿。
- 客户端全量 222/222;网关换新产物后 Web 依旧正常(相对路径在 `/` 下同样成立,
  实测渲染出收件箱、无控制台错误),并真发一封邮件确认回信到达。

## 判据自己的错(记一笔)

第一次跑时「附件真的挂在信上」报红,而库里那 41 字节的附件**明明挂在信上** ——
我把端点写成了 `/me/mail/{id}`(不存在,404),正确是 `/mail/{id}`。
判据用错端点时以「附件是空的」现形,看起来像功能 bug。

另:`pkill -f 'agentmail-web'` 会把**自己这条命令**也杀掉(命令行里含同样的字符串),
表现是「脚本没有任何输出、退出码 143」。改用端口定位(`ss -tlnp | grep :9223`)。
2026-09-12 20:25:00 +08:00

90 lines
4.3 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';
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'
);
});