Files
MailUI4Agents/client/electron/test/appearance-defaults.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

243 lines
16 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.

// 外观的**服务端契约**与**本机缓存的键**:两个客户端的默认值必须等于服务端那三个数,
// 缓存键必须按账号分。
//
// 为什么值得单独一个判据文件:
// 1. 服务端 `DefaultAppearance()` 的注释宣称"与客户端 backgroundStore / themeStore 的
// 默认值一致" —— 在 WebUI 用 24/8 时那句话**是假的**pi 2026-09-14 更正了自己
// 上一封"数值是审美"的说法:它是契约问题)。
// **措辞要准**pi 同封指出):这条判据核对的是"**与这份服务端源码的契约一致**"
// **不是**"在跑的那个服务端二进制是 12/4" —— 与"dist 是产物、源码修好≠用户手上的包修好"
// 同构。若服务端由别的流水线构建部署,这条判据对运行时**没有**发言权。
// 2. 默认值决定"新账号的初始外观":服务端"没有记录"时客户端以本地为准推上去,
// 于是**谁先同步谁决定**。24/8 与 12/4 的差别不是审美,是同一个账号在不同客户端
// 登录会得到不同的压暗强度。
// 3. 缓存键是"换账号串味"的成因(全局键 → saved=false 时把上一个账号的外观推上去)。
import { code, prose } from './lib/read.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
// 与其它判据同一个约定HERE = client/electron/testROOT = 仓库根
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const SERVER_MODELS = join(ROOT, 'server/internal/models/models.go');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
/** 从 Go 源码里读服务端契约(不复制一份数字到判据里 —— 那又会变成"两处各写一套" */
function serverDefaults() {
const src = prose(SERVER_MODELS);
// 只取 DefaultAppearance 函数体,避免匹配到别的结构体字面量
const at = src.indexOf('func DefaultAppearance()');
assert.ok(at > 0, '服务端要有 DefaultAppearance()');
let depth = 0;
let end = at;
for (let i = src.indexOf('{', at); i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = src.slice(at, end + 1);
/*
* pi 2026-09-14这类"去源码里读值"的判据必须能区分三种情况,且都不许静默放行:
* ① 切不出函数体(改名/挪位置)→ 红(上面的 `at > 0` 已经挡了,这里再加一道空体检查);
* ② 字段**在**但值不是字面量(例如 `BgDim: defaultDim`)→ 报"**读不懂**",红;
* ③ 字段**根本不在**(重排/删掉)→ 红。
* ②③ 分开报,因为修法不一样:②是要人去看服务端怎么算的,③是判据要跟着字段走。
* 静默绿是这类判据最危险的失败方式 —— 与"自报条数 < 登记条数"同族。
*/
assert.ok(body.replace(/[\s{}]/g, '').length > 0,
'DefaultAppearance 的函数体切出来是空的(函数被改名/挪走了?判据要跟着改,别静默放行)');
/*
* pi 2026-09-14 补充的一条:**标识符应该能解析一层**,否则「读不懂就红」的长期结局是
* 有人做一次无害重构(`BgDim: defaultDim`)→ 判据红 → 唯一出路是把判据改宽 →
* 下一步通常是"少核一个字段" → 契约又漂了。
* 所以:字面量直接用;标识符在同一文件里查 `NAME = <字面量>`;查不到(跨包 / 计算 / iota
* 才报"读不懂"。**只解析一层**:再深就不是"读一个常量"而是"执行 Go"了,那时该报读不懂。
*/
const resolve = (name, reLiteral, identRe, what) => {
assert.ok(new RegExp(`\\b${name}\\s*:`).test(body), `DefaultAppearance 里没有 ${name} 字段了(判据要跟着服务端改)`);
const lit = reLiteral.exec(body);
if (lit) return lit;
const ident = identRe.exec(body);
assert.ok(ident, `${name} 在,但值既不是${what}也不是标识符 —— **判据读不懂**,请人工核对接线。函数体:${body.replace(/\s+/g, ' ')}`);
const identName = ident[1];
/*
* 同一文件里找 `NAME = <字面量>`(也覆盖 const 组里的 `NAME = 12`)。
* **只解析一层**:再深就不是"读一个常量"而是"执行 Go"了 —— 那时该报读不懂,
* 让人来看,而不是判据自己猜。
*/
const declRe = new RegExp(`\\b${identName}\\s*=\\s*([^\\n]+)`, 'g');
const decls = [...src.matchAll(declRe)].map(m => m[1].replace(/\/\/.*$/, '').trim());
const literalOf = what === '数字字面量' ? /^(\d+)$/ : /^"([^"]+)"$/;
for (const v of decls) {
const hit = literalOf.exec(v);
if (hit) return hit;
}
assert.fail(
`${name} 指向标识符 \`${identName}\`,但同一文件里找不到它的字面量定义 —— ` +
`**判据读不懂**(跨包 / 计算 / iota请人工核对。找到的声明${decls.join('、') || '(无)'}`
);
};
const dim = resolve('BgDim', /BgDim:\s*(\d+)/, /BgDim:\s*([A-Za-z_]\w*)/, '数字字面量');
const blur = resolve('BgBlur', /BgBlur:\s*(\d+)/, /BgBlur:\s*([A-Za-z_]\w*)/, '数字字面量');
const theme = resolve('Theme', /Theme:\s*"([^"]+)"/, /Theme:\s*([A-Za-z_]\w*)/, '字符串字面量');
const kind = resolve('BgKind', /BgKind:\s*"([^"]+)"/, /BgKind:\s*([A-Za-z_]\w*)/, '字符串字面量');
const preset = resolve('BgPresetID', /BgPresetID:\s*"([^"]+)"/, /BgPresetID:\s*([A-Za-z_]\w*)/, '字符串字面量');
return {
dim: Number(dim[1]),
blur: Number(blur[1]),
theme: theme[1],
kind: kind[1],
preset: preset[1]
};
}
test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里写死数字)', () => {
const s = serverDefaults();
assert.deepEqual([s.dim, s.blur], [12, 4], '服务端 DefaultAppearance 是 12/4权威值');
// WebUI默认值必须来自共享常量且等于服务端
const defaults = code(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'));
assert.match(defaults, new RegExp(`DEFAULT_DIM = ${s.dim}\\b`), `DEFAULT_DIM 要等于服务端的 BgDim=${s.dim}`);
assert.match(defaults, new RegExp(`DEFAULT_BLUR = ${s.blur}\\b`), `DEFAULT_BLUR 要等于服务端的 BgBlur=${s.blur}`);
// 严格store 里不许再自写一套数字(原来这里写的是 24/8与服务端不一致
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const defBlock = /export const DEFAULT_BACKGROUND: BackgroundState = \{[\s\S]*?\};/.exec(store);
assert.ok(defBlock, 'DEFAULT_BACKGROUND 要能取到');
assert.match(defBlock[0], /dim:\s*DEFAULT_DIM/, 'dim 要引用共享常量,不许自写字面量');
assert.match(defBlock[0], /blur:\s*DEFAULT_BLUR/, 'blur 要引用共享常量');
assert.ok(!/dim:\s*\d/.test(defBlock[0]), `DEFAULT_BACKGROUND 里不许再出现数字字面量(现在:${defBlock[0].replace(/\s+/g, ' ')}`);
// 同步层的兜底值也要引用同一常量(原来 clamp(..., 12, 4) 是另写的一份)
const lib = code(join(ROOT, 'client/electron/src/lib/appearance.ts'));
const clamps = [...lib.matchAll(/clamp\([^)]*\)/g)].map(m => m[0]);
assert.ok(clamps.length >= 4, `clamp 调用要能取到(实际 ${clamps.length} 处)`);
for (const c of clamps) {
assert.ok(!/,\s*\d+\s*\)$/.test(c), `clamp 的兜底值要引用常量,不许写死:${c}`);
}
// 鸿蒙:默认值(在 AppearanceResponse 的字段默认值里)与预设也要与服务端对得上
const ap = code(join(ETS, 'model/Appearance.ts'));
assert.match(ap, new RegExp(`bg_dim: number = ${s.dim}\\b`), `鸿蒙默认压暗要等于服务端 BgDim=${s.dim}`);
assert.match(ap, new RegExp(`bg_blur: number = ${s.blur}\\b`), `鸿蒙默认模糊要等于服务端 BgBlur=${s.blur}`);
assert.match(ap, new RegExp(`theme: string = '${s.theme}'`), `鸿蒙默认主题要等于服务端 Theme=${s.theme}`);
const wp = code(join(ETS, 'model/Wallpaper.ts'));
assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`);
// 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。
// (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。)
const models = prose(SERVER_MODELS);
const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models);
assert.ok(comment, 'DefaultAppearance 上面要有说明注释');
assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)');
});
test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => {
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
// 键函数:必须把 accountId 拼进去(按块取函数体,不看调用点 —— "配对/解析"那条)
const at = store.indexOf('export function storageKey(');
assert.ok(at > 0, '要有 storageKey 函数');
/*
* ⚠️ 只取**函数体**(第一个 `{` 到配对的 `}`),不能把签名一起算进来 ——
* 我第一版就是从 `export function` 开始切、然后在整段里找 `accountId … STORAGE_KEY_PREFIX`
* 结果**变异测试抓出了这个判据是假的**:把实现改成 `return STORAGE_KEY_PREFIX;`(退回全局键)
* 时它依然绿,因为参数表里的 `accountId` 已经满足了那个正则。
* 这正是本仓规范第 1 条(判结构要配对/解析,不要靠邻接与窗口)—— 我写规范时没想到
* 它的适用对象也包括"这个函数自己有没有把账号拼进去"。
*/
const bodyStart = store.indexOf('{', at);
let depth = 0;
let end = at;
for (let i = bodyStart; i < store.length; i++) {
if (store[i] === '{') depth++;
else if (store[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = store.slice(bodyStart + 1, end);
/*
* ⚠️ **这里原来有一条正则断言,已删除**pi 2026-09-14 用反例钉死):
* `const k = STORAGE_KEY_PREFIX + accountId; return STORAGE_KEY_PREFIX;`
* ——"常量 + 账号拼接"确实**在函数体里**,但 `return` 的是全局键。
* 实测:第三版判据对这种写法**仍然全绿**(我跑过),退化和第一版一样完整。
* 教训:**判据的作用对象是"值/行为"时,不要退化成对源码形状的匹配** ——
* 这一族"源码里有没有那个形状"的判据永远差一个反例。
* 权威已经交给行为判据:`test/stores/background.test.ts` 直接断言
* storageKey('acct-a') !== storageKey('acct-b')
* storageKey('acct-a') === 'agentmail.background.acct-a'
* storageKey('') !== 'agentmail.background'(不退回全局键)
* 那三条对"拼了没用上""拼了又丢掉""换个名字的退化"都会红,且不误伤合法写法。
*
* 下面保留的是**别的东西**:不许写死键名(键必须来自 storageKey())。
* 那是"来源"约束,不是"值对不对",正则在这里是合适工具。
*/
/*
* 行为面由 vitest 兜底(`test/stores/background.test.ts` 直接断言
* storageKey('acct-a') === 'agentmail.background.acct-a' 且两个账号不相等)——
* 静态判据只负责"形状",值对不对由真跑一遍的函数说了算。
*/
/*
* 不许写死键名:写入的键只能是 `storageKey()`,或由它算出来的变量(迁移时写 `key`)。
* 我第一版写成"只允许字面量 setItem(storageKey(" —— 结果把迁移那次合法写入
* `setItem(key, legacy)`key 就是按账号的键)也判红了;判据要钉**键的来源**
* 不是调用的字面形状。
*/
const badWrites = [...store.matchAll(/setItem\(([^,)]+)/g)]
.map(m => m[1].trim())
/*
* `key` = 按账号算出来的键;`LEGACY_BACKUP_KEY` = 迁移前留的手工恢复备份
* (第三次被这类"合法写入"误伤:判据钉的是**键的来源**,不是调用的字面形状)。
*/
.filter(k => !/^storageKey\(/.test(k) && k !== 'key' && k !== 'LEGACY_BACKUP_KEY');
assert.deepEqual(badWrites, [], `写入的键必须来自 storageKey()(现在这些不是:${badWrites.join('、')}`);
assert.match(store, /removeItem\(LEGACY_STORAGE_KEY\)/, '旧全局键要被删除(否则下一个账号继续从它继承)');
/*
* 备份键(`LEGACY_BACKUP_KEY`**只许写、不许读** —— 那是它无害的全部理由:
* 一旦有人读它,它就变成了第二个"继承源",刚修掉的串味会从这条路回来。
*/
const backupReads = [...store.matchAll(/getItem\(([^,)]+)\)/g)].map(m => m[1].trim())
.filter(k => k === 'LEGACY_BACKUP_KEY');
assert.deepEqual(backupReads, [], '备份键只能写不能读(否则它就成了新的"继承源"');
// 读/写都要走按账号的键(`key` 由 storageKey() 算出,见函数开头)
assert.match(store, /const key = storageKey\(accountId\)/, '键要先按账号算出来');
assert.match(store, /let raw = localStorage\.getItem\(key\)/, '读缓存要按账号的键');
assert.match(store, /localStorage\.setItem\(storageKey\(\), JSON\.stringify\(state\)\)/, '写缓存要按账号的键');
// 鸿蒙侧:键同样带账号(两边形状一致,这条差异已经消除)
const ets = code(join(ETS, 'common/AppearanceStore.ets'));
const eAt = ets.indexOf('prefKey(accountId: string)');
assert.ok(eAt > 0, '鸿蒙要有按账号取键的函数');
let d2 = 0;
let e2 = eAt;
for (let i = ets.indexOf('{', eAt); i < ets.length; i++) {
if (ets[i] === '{') d2++;
else if (ets[i] === '}') { d2--; if (d2 === 0) { e2 = i; break; } }
}
/*
* 鸿蒙这半**只能**静态判:`.ets` 在本机跑不起来(编译要 hvigorw运行要设备
* 而设备/模拟器在这条链上不可用,见计划文档 §7.21 的实测阻塞)。
* 所以上面那条"值/行为优先"的规则在这里让位于正则 —— 但要把限制写明:
* 这条只证明"函数体里有把账号拼进键的形状"**不证明**拼出来的值对。
*/
assert.match(ets.slice(eAt, e2 + 1), /KEY_PREFIX\s*\+\s*accountId/, '鸿蒙的键也要拼账号(静态判据,值未经运行验证)');
});
test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端', () => {
/*
* 服务端"没有记录"时 `pull()` 会"以本地为准推上去"——所以重读必须在前,
* 否则推上去的是上一个账号的外观(全局键时代就是这个现象,而且写进了服务端)。
*/
const sync = code(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'));
const sub = /activeId !== prev\.activeId\)[\s\S]{0,400}?\}\);/.exec(sync);
assert.ok(sub, '要能取到账号切换的处理块');
const block = sub[0];
const reloadAt = block.indexOf('reloadForAccount()');
const pullAt = block.indexOf('.pull()');
assert.ok(reloadAt > 0, `切换账号要先重读本账号的缓存:${block.replace(/\s+/g, ' ')}`);
assert.ok(pullAt > 0, '切换账号仍然要拉服务端');
assert.ok(reloadAt < pullAt, 'reloadForAccount() 必须在 pull() 之前(顺序就是这条判据的全部意义)');
// 重读本身不许落盘(它只是把本账号已有的值读回来,不是用户的改动)
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const rAt = store.indexOf('reloadForAccount: () => {');
assert.ok(rAt > 0, 'store 要提供 reloadForAccount');
const rBlock = store.slice(rAt, store.indexOf('}', store.indexOf('set(next)', rAt)));
assert.ok(!/persist\(/.test(rBlock), '重读不许写盘');
});