Files
MailUI4Agents/client/electron/test/harmony-system-api.test.mjs
JianFeeeee 0ce8b29546 test(criteria): 判据规范 CRITERIA.md(邻接不是结构·第三次露头)+ 废弃 API allow-list 结构化 + 扫描范围自检
pi 的两条增量,都不需要他再确认。

## 1(pi 建议):生成清单 + 空 allow-list 的结构性风险

他的推演:SDK 升版会往清单里加新条目 → 某天早上套件**突然红**,且红在与本次改动无关的
代码上;这时人的第一反应是把名字塞进 allow-list —— 而 allow-list 一旦这么用,
就不再是"研究过的例外",只是"红的止痛药"。所以条目结构化:

```js
const ALLOW = [ /* { name, replacement, why } */ ];
```

`replacement` 非空是硬断言("暂时不想改"不是放行理由,"替代品要求的 API level 高于基线"才是)。
**刻意不断言"名单必须为空"** —— 那会挡住合理放行;断言的是"有名字、没替代品 → 红",
于是侵蚀发生时红的是**放行这件事本身**,而不是某天的新 SDK。
变异:塞一条 `{ name:'px2vp', replacement:'', why:'暂时不想改' }` → 红。

## 2(pi 建议):扫目录的判据要防"空判据"

他问废弃 API 判据扫哪些目录(怕只扫 `pages/`,`common/` 里的旧写法逃掉)。
答案:扫的是**整个 ets 目录递归**(实测 25 个文件,含 `pages/ common/ model/ api/ entryability/`)。
顺手加了防退化的自检:文件数 ≥ 20,且 `pages/ common/ model/ api/` 四个目录都必须扫到
—— 目录改名/只扫一个子目录会让这条变成空判据而依然全绿。
变异:把扫描范围改成只扫 `pages/` → 红。

## 3(pi 建议):把"邻接不是结构"写进判据规范

同一个坑在本仓露头三次:① 窗口式正则被一行注释挤爆(原注释自嘲过);
② 括号配对取代窗口;③ 链式修饰符让"看前一个字符是不是 `}`"静默失效。
共同形式值得升格成规则,于是新建 `client/electron/test/CRITERIA.md`(七条),
并在 `run-all.mjs` 加**自检 3**:规范文件必须在、且必须点到关键条目。
WebUI 侧 `background.test.mjs` 的窗口式存量按 pi 的说明**记着不动**(那是他的地盘)。

规范里另外两条是本仓自己踩出来的:剥注释读代码 vs 读原文读理由(混用必红);
以及**验证要按真实入口跑** —— 我用 `node --test test/run-all.mjs` 验自检 3 时它"依然绿",
其实是 runner 把内部的 `process.exit(1)` 吞了;换成 `npm test` 走的那一行就红对了。
变异:删掉规范里的一条关键规则 → `npm test` 那条路 exit 1。

## 验证

`npm test` 退出码 0(11 个判据文件全绿 + vitest 258/258);`hvigorw assembleHap` 未受影响。
2026-09-14 14:51:30 +08:00

314 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 { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, 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(readFileSync(ID_TABLE, 'utf8')).record;
const byName = new Map();
for (const r of recs) byName.set(r.name, r.type);
return byName;
};
/** SDK 的 BlurStyle 成员 */
const loadBlurStyleMembers = () => {
const src = readFileSync(COMMON_DTS, 'utf8');
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 = readFileSync(f, 'utf8').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 = readFileSync(f, 'utf8').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 = readFileSync(f, 'utf8');
// 先切出 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 = readFileSync(f, 'utf8').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 命中统计');
});