Files
MailUI4Agents/client/electron/test/harmony-system-api.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

315 lines
15 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.

/**
* 「用系统方案」的可验证部分 —— 系统资源名与系统材质取值,**不需要设备**。
*
* 背景jianf 追加要求「鸿蒙也同步,但是鸿蒙要求用系统方案」(详见
* `docs/HARMONY-ALIGN-PLAN.md` §二·五 / §7.10)。方向很清楚:能交给系统的就交给系统 ——
* `backgroundBlurStyle(BlurStyle.*)` 取代手写模糊、`$r('sys.color.*')` 语义色取代自切变量。
*
* 难点在于**这台机器上没有设备**(模拟器需要人在命令行启动,本沙箱起不来):
* `$r('sys.color.写错了')` 这种错**编译期不报**,只有真机运行到那一行才炸。
* 于是"用系统方案"会变成一块谁都验不了的区域 —— 那正是本仓库一直在防的东西。
*
* 解决办法是 SDK 自带的那张表:`sdk/default/openharmony/toolchains/id_defined.json`
* 列了**全部系统资源名及其类型**(本机 API 267826 条,其中 color 1059 条)。
* BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。
* 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。
*/
import { prose } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS_DIR = join(ROOT, 'client/harmony/entry/src/main/ets');
/** DevEco CLI 的 SDK 根。可用 HARMONY_CLT 覆盖(换机器时不用改判据)。 */
const CLT = process.env.HARMONY_CLT || '/opt/huawei/command-line-tools';
const ID_TABLE = process.env.HARMONY_ID_TABLE
|| join(CLT, 'sdk/default/openharmony/toolchains/id_defined.json');
const COMMON_DTS = join(CLT, 'sdk/default/openharmony/ets/component/common.d.ts');
/**
* SDK 名表不在就没法验 —— 这时**必须判红**,不能"跳过"。
* 一条会自己静默跳过的判据,等于没有这条判据(本仓库已经栽过四次同类问题)。
*/
const missingSdk = [];
if (!existsSync(ID_TABLE)) missingSdk.push(ID_TABLE);
if (!existsSync(COMMON_DTS)) missingSdk.push(COMMON_DTS);
test('SDK 的系统资源名表与组件声明要在本机找得到(找不到就明说,不许静默跳过)', () => {
assert.deepEqual(
missingSdk,
[],
`找不到 SDK 文件,这条判据什么都验不了:\n ${missingSdk.join('\n ')}\n`
+ '若 SDK 换了位置,用 HARMONY_CLT 或 HARMONY_ID_TABLE 指向它,'
+ '不要在没核过的情况下把这条判据删掉。'
);
});
/** 收集鸿蒙源码(.ets / .ts */
const collectSources = (dir, acc = []) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) collectSources(full, acc);
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
}
return acc;
};
/** SDK 名表name → type */
const loadIdTable = () => {
const recs = JSON.parse(prose(ID_TABLE)).record;
const byName = new Map();
for (const r of recs) byName.set(r.name, r.type);
return byName;
};
/** SDK 的 BlurStyle 成员 */
const loadBlurStyleMembers = () => {
const src = prose(COMMON_DTS);
const at = src.indexOf('declare enum BlurStyle');
assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle');
// 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,`
// (早先的写法把文档里的 `T`、`R` 也算成了成员 —— 名字解析必须精确到分隔符)
const block = src.slice(at, src.indexOf('\n}', at));
return new Set(
[...block.matchAll(/^ {2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1])
);
};
/** 扫描 `$r('sys.<type>.<name>')` 用法 → [{file, type, name}] */
const scanSysResources = (files) => {
const found = [];
for (const f of files) {
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
for (const m of src.matchAll(/\$r\(\s*'sys\.([a-z]+)\.([A-Za-z0-9_]+)'/g)) {
found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] });
}
}
return found;
};
/** 扫描 `BlurStyle.<MEMBER>` 用法 → [{file, member}](注释已剥离) */
const scanBlurStyles = (files) => {
const found = [];
for (const f of files) {
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
for (const m of src.matchAll(/BlurStyle\.([A-Z][A-Z_0-9]*)/g)) {
found.push({ file: f.slice(ROOT.length + 1), member: m[1] });
}
}
return found;
};
test('源码里引用的系统资源名都必须在 SDK 名表里且类型相符(否则真机才炸)', () => {
const files = collectSources(ETS_DIR);
assert.ok(files.length >= 10, `应扫到至少 10 个源文件,实际 ${files.length}`);
const table = loadIdTable();
assert.ok(table.size > 1000, `SDK 名表应有一千条以上,实际 ${table.size}`);
const used = scanSysResources(files);
const bad = used.filter(u => table.get(u.name) !== u.type);
assert.deepEqual(
bad.map(u => `${u.file}: sys.${u.type}.${u.name}(表里是 ${table.get(u.name) ?? '查无此名'}`),
[],
'系统资源名写错/类型不匹配 —— 这类错误编译期不报,只有真机运行到那一行才炸'
);
// 反向对照:判据要真能抓到一个不存在的名字(否则它只是"跑过了"
const ghost = 'backgroundColor($r(\'sys.color.ohos_id_color_这个词不存在\'))';
const ghostName = ghost.match(/sys\.color\.([A-Za-z0-9_]+)/)[1];
assert.notEqual(table.get(ghostName), 'color', '自检:判据抓不到不存在的系统色名');
});
test('BlurStyle 的取值必须在 SDK 的 enum 里(系统材质的名字同样不能凭记忆写)', () => {
const members = loadBlurStyleMembers();
assert.ok(members.size >= 8, `BlurStyle 应有 8 个以上成员,实际 ${members.size}`);
const used = scanBlurStyles(collectSources(ETS_DIR));
const bad = used.filter(u => !members.has(u.member));
assert.deepEqual(
bad.map(u => `${u.file}: BlurStyle.${u.member}`),
[],
`这些 BlurStyle 取值在 SDK 里不存在,可用:${[...members].join(' / ')}`
);
assert.ok(members.has('COMPONENT_REGULAR'), '自检SDK 里应有 COMPONENT_REGULAR');
});
test('替换成系统方案时要用的那批系统色,先在这里核过(计划先于代码)', () => {
/*
* 这张清单是"WebUI 做法 → 鸿蒙系统能力"的对应表(文档 §二·五)落到**具体资源名**上。
* 先在这里核一遍再动代码:名字我核过了,替换时就不会一边写一边猜。
*
* ⚠️ 注意表里**没有** brand / confirm / success 这类名字 —— 系统只给"情绪色"
* warning / alert。所以权限三档与预算三档这些**业务语义色没有系统对应物**
* 必须继续用自定义令牌:为了"用系统色"把 plan 档画成 warning 色,是把语义丢了换形式。
*/
const table = loadIdTable();
const intent = {
color: [
'ohos_id_color_list_card_bg', // 列表项 = 每项一张卡(底色)
'ohos_id_color_list_separator', // 列表分隔线
'ohos_id_color_background', // 页面底
'ohos_id_color_sub_background', // 次级表面
'ohos_id_color_text_primary',
'ohos_id_color_text_secondary',
'ohos_id_color_text_tertiary',
'ohos_id_color_emphasize', // 强调(品牌蓝的落点:具体取值仍须与 WebUI 对齐)
'ohos_id_color_warning',
'ohos_id_color_alert',
'ohos_id_color_mask_regular' // 遮罩
]
};
for (const [type, names] of Object.entries(intent)) {
for (const n of names) {
assert.equal(table.get(n), type, `计划要用的 sys.${type}.${n} 在 SDK 名表里不是 ${type} 类型`);
}
}
// 边界:业务语义色没有系统对应物,这一点也钉住 —— 免得以后有人"为了系统化"去硬套
for (const ghost of ['ohos_id_color_brand', 'ohos_id_color_confirm', 'ohos_id_color_success']) {
assert.equal(table.get(ghost), undefined, `${ghost} 竟然存在了?那这条边界要重新核一遍`);
}
});
/*
* ─────────── 废弃 API**清单从 SDK 生成**,不手写 ───────────
*
* pi 的建议2026-09-14与其一条条手写"不得再用全局 `promptAction.showToast`"
* 不如从 SDK 的 `@deprecated` 标记**生成**一份清单,再配一份人工 allow-list ——
* 新增一条默认判红,除非有人显式放行。这与 `$r('sys.*')` 名字表的做法是同一个套路。
*
* 手写清单的死法是"只挡已经踩过的那一个":这次修了 23 处,下一个人写个新的废弃 API
* 照样全绿 —— 而 SDK 自己知道哪些废弃了,问它就行。
*/
/** 递归收集 SDK 的 .d.ts含 api/ 与 component/ */
function sdkDtsFiles() {
const roots = [
join(CLT, 'sdk/default/openharmony/ets/component'),
join(CLT, 'sdk/default/openharmony/ets/api')
];
const out = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (e.name.endsWith('.d.ts')) out.push(full);
}
};
for (const r of roots) if (existsSync(r)) walk(r);
return out;
}
/**
* 顶层(不在 namespace/class 里)被标 `@deprecated` 的 `declare function` 名字。
*
* "顶层"这件事必须判:`declare namespace fileIo { declare function open(...) }` 里的
* `open` 是**命名空间成员**,全局调 `open(...)` 并不会走到那个废弃实现 ——
* 把它算进来会造出一堆假红(这一版就是按花括号深度筛出来的)。
*/
function deprecatedGlobalFunctions() {
const names = new Set();
for (const f of sdkDtsFiles()) {
const src = prose(f);
// 先切出 depth == 0 的片段
let depth = 0;
let segStart = 0;
const segs = [];
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '{') {
if (depth === 0) segs.push([segStart, i]);
depth++;
} else if (c === '}') {
depth--;
if (depth === 0) segStart = i + 1;
}
}
if (depth === 0) segs.push([segStart, src.length]);
const top = segs.map(([a, b]) => src.slice(a, b)).join('\n');
for (const m of top.matchAll(/\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*declare function (\w+)\s*\(/g)) {
if (m[1].includes('@deprecated')) names.add(m[2]);
}
}
return names;
}
test('废弃 API清单**从 SDK 生成**,源码里不得调用(新增一条默认判红)', () => {
if (missingSdk.length) return; // 没有 SDK这一条无从判起由文件顶部统一报
const deprecated = deprecatedGlobalFunctions();
assert.ok(deprecated.size > 20, `要从 SDK 读到一批废弃的全局函数,实际 ${deprecated.size}`);
// 清单真的抓到了我们关心的东西(否则"生成"这件事本身没生效)
for (const must of ['animateTo', 'getContext', 'px2vp']) {
assert.ok(deprecated.has(must), `清单里应该有 ${must}(它已被 SDK 标记废弃)`);
}
// 全局 showToast 不是 `declare function`(它是命名空间成员),由 harmony-logic 那条单钉
/**
* 允许的例外:**每条都要写"替代品"和"为什么现在不能换"**。
*
* pi 指出这套组合(生成的清单 + 空 allow-list有个结构性风险
* SDK 升版会往清单里加新条目 → 某天早上套件**突然红**,且红在与本次改动无关的代码上;
* 这时第一反应是把名字塞进 allow-list而 allow-list 一旦这么用,
* 就不再是"研究过的例外",只是"红的止痛药"。
*
* 所以条目结构化成 `{ name, replacement, why }`,并断言 `replacement` **非空**
* "暂时不想改"不是放行理由,"替代品要求的 API level 高于本机基线"才是。
* **刻意不断言"名单必须为空"** —— 那会挡住合理放行;但"有名字、没替代品"必须红,
* 这样侵蚀发生时红的是**放行这件事本身**,而不是某天的新 SDK。
*/
const ALLOW = [];
// 名单形状自检:每条都必须有替代品与理由(防止以后有人只塞个名字进来)
for (const entry of ALLOW) {
assert.ok(typeof entry.name === 'string' && entry.name.length > 0, 'allow-list 条目要有 name');
assert.ok(typeof entry.replacement === 'string' && entry.replacement.length > 0,
`allow-list 里 ${entry.name} 没写"替代品" —— 没有替代品的放行不是例外,是止痛药`);
assert.ok(typeof entry.why === 'string' && entry.why.length > 0,
`allow-list 里 ${entry.name} 没写"为什么现在不能换"`);
}
const walk = (dir, acc = []) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full, acc);
else if (/\.(ets|ts)$/.test(e.name)) acc.push(full);
}
return acc;
};
/*
* 扫描范围 = **整个 ets 目录递归**`pages/` `common/` `model/` `api/` `entryability/` …),
* 不是只扫 `pages/` —— pi 点过这条:只扫页面的话 `common/` 里的旧写法会逃掉,
* 而"新代码照抄旧模块"这条路径最常发生在 `common/`。
*
* "扫到的文件数 ≥ N"这条自检是防它**悄悄退化**(目录改名、遍历写错、
* 只扫了一个子目录都会让这条判据变成空判据而依然全绿)——
* 与 `cross-client-theme` 里给 pages 加的那条同形。
*/
const files = walk(ETS_DIR);
const scannedDirs = new Set(files.map(f => f.slice(ETS_DIR.length + 1).split('/')[0]));
assert.ok(files.length >= 20,
`这条判据要扫到整个 ets 目录(至少 20 个文件),实际 ${files.length} 个 —— 扫描范围退化了`);
for (const dir of ['pages', 'common', 'model', 'api']) {
assert.ok(scannedDirs.has(dir), `扫描范围要包含 ${dir}/(否则那里的旧写法会逃掉)`);
}
const hits = [];
for (const f of files) {
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const name of deprecated) {
// 只算**全局调用**:排除成员调用 `x.name(``.animateTo(` 是我们要求的新写法)
const re = new RegExp(`(?<![.\\w])${name}\\s*\\(`, 'g');
if (re.test(src)) hits.push(`${f.slice(ETS_DIR.length + 1)}: ${name}`);
}
}
const allowed = hits.filter(h => ALLOW.some(a => h.includes(a.name)));
const bad = hits.filter(h => !ALLOW.some(a => h.includes(a.name)));
assert.deepEqual(bad, [],
`这些地方调了 SDK 已标记废弃的**全局**函数:${bad.join('、')} —— ` +
'换成新写法(如 UIContext 上的同名方法),或把它登记进 ALLOW 并写明理由');
assert.ok(allowed.length === ALLOW.length || allowed.length >= 0, 'allow-list 命中统计');
});