Files
MailUI4Agents/client/electron/test/packaging.test.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

148 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 { 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'
);
});
/**
* ★ 发布脚本的**行为**判据:构建失败必须**真的停下**,而不是"看起来串起来了"。
*
* 为什么不是形状检查:这一族的病根是**退出码**。`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万一以后有人加了管道退出码仍是构建的');
});