Files
MailUI4Agents/client/electron/test/harmony-apibase.test.mjs
JianFeeeee e29f38b151 跨端: 页面转场 + 主题切换交叉淡出(用户:「加页面转场,因为是桌面应用了」+「深浅色切换也加动画」)
## ① 页面间转场(`pageTransition`)

用户要的是"因为它是桌面应用了"—— 桌面端的页面切换有转场是基本预期。

官方文档(`ts-page-transition-animation`)给了两条关键信息:
· 「当路由(**router**)进行切换时,可以通过在 `pageTransition` 函数中自定义
  页面入场和页面退场的转场动效」;
· 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。

⇒ 所以**两套机制各归各位**,不混用:
  · 走 `router` 的独立页(管理页 / 邮件详情独立页 / 写信独立页)→ `pageTransition`;
  · 应用内 `Navigation` 跳转(邮件详情 / 写信,都走 `pushPath`)→ 已有
    `geometryTransition`(上一轮加的共享元素转场)。

数值照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`)逐字对齐:
    from { opacity: 0; transform: translateY(4px) }
    to   { opacity: 1; transform: none }
⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。

★ 退场**只淡出、不做位移**:两端都位移会看起来互相推挤。

## ② 主题切换的交叉淡出

这是**平台机制差异**,不是"懒得做":

· WebUI 切主题是**免费**的 —— CSS transition 挂在 `background-color` 上,
  主题换的是 CSS **变量**,于是每个元素各自补间(`index.css:1169` 给
  button/a/input/textarea/select 都挂了);
· ArkUI 的主题走 `app.setColorMode()`,而界面颜色是**系统资源**
  (`$r('sys.color.*')`)。`animateTo` 只能补间**数值属性** ——
  "把一个资源换成另一个资源"没有中间值 ⇒ 实测整屏同时跳变(闪一下)。

⇒ 自己造一个"旧屏淡出":
  ① 切主题**之前**用 `getComponentSnapshot().get(id)` 抓当前界面;
  ② 立刻切主题(底层已是新主题);
  ③ 把位图盖在最上层、`opacity` 1 → 0 补间 ⇒ "旧界面淡出、新界面透出"。

新增:
· `Motion.captureForThemeFade(ui, rootId)` —— 抓图,失败返回 `undefined`;
· `Theme.durThemeFade = 260`(整屏变化比单组件入场慢一点才不刺眼);
· `MainPage` 根 Stack 加 `.id(THEME_FADE_ROOT_ID)`(**常量**,两处拼错不报错、
  只表现为"动画静默不发生")+ `themeFadeImage`/`themeFadeOpacity` 两个状态
  + 最上层那层 `Image`;
· `toggleTheme` 拆成"带动画"与 `applyThemeNow`(**不含动画**)两条路,
  共用同一份状态变更 —— 抓图失败 / 系统关掉动画时直接切主题
  (**功能优先于观感**:不能因为动画失败而切不了主题)。

★ 两个必须做的收尾(都不是可选):
  · 动画结束后**清空 `themeFadeImage`** —— 留着是一张**整屏大小**的 PixelMap
    常驻内存,而且它盖在最上层会**吃掉所有触摸事件**(界面看着正常但点不动);
  · 那层加 `hitTestBehavior(HitTestMode.None)` —— 动画期间用户可能正好在点按钮。

★ 默认跟随系统:**本来就已经是**(`Appearance.theme` 初值 `'system'`,
  `AppearanceStore` 与 `Models` 两处都是),本轮未改,只是确认了。

## 验证状态(如实)

✓ 编译通过;设备上装包后进程存活、无新 jscrash
✗ **动画本体没有在设备上看到**:
  · 页面转场与主题淡出都是 200-260ms,而 `snapshot_display` 往返 1.5-3s
    ⇒ 探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`);
  · 主题淡出还额外需要"已登录 + 找到主题开关",而我在清 preferences 之后
    登录流程没能用 `uitest uiInput` 走通(`inputText` 追加而非替换,
    把地址栏填成了 `https://exm/api/v1https://mail.jianfgit.`)。

⇒ 这两条的**结构与接线**有判据/编译保障,但"动起来好不好看"只能由真机确认。
   我不声称已验 —— 与既有的 morph 那条同一个诚实口径。
2026-09-21 17:36:40 +08:00

349 lines
23 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.

/*
* 鸿蒙客户端「连不上服务器」那一族(apiBase 归一化/校验 + 失败说人话)的判据。
*
* ── 背景(2026-09-15)──
*
* 用户报"鸿蒙客户端连不上服务器",而服务端 `https://mail.jianfgit.xyz/health` 是 200
* (证书 Let's Encrypt、TLS 校验通过)。两个坑叠在一起:
* ① 默认地址是 `http://192.168.2.60:8180/api/v1` —— 明文 + 写死内网 IP,
* 手机不在这个网段就永远连不上,而报错只说"无法连接";
* ② `apiBase` 是**完整 API 前缀**(`ApiClient` 拼的是 `'/auth/login'` 这类相对路径),
* 用户只填 `https://mail.jianfgit.xyz/` 时请求变成 `https://…/auth/login`
* ⇒ 服务端 **404**,界面依旧只显示"无法连接" —— **本次最可能的直接原因**。
*
* ── 判据怎么分层(按 `test/CRITERIA.md` §6.7.0 的分流)──
*
* · **值**:`model/ApiBase.ts` 是纯逻辑(无 `@ohos` 依赖,类型可擦除),
* 用 node 的 `--experimental-strip-types` **直接执行它** —— 补后缀、协议保护、
* 校验、失败分类全部是**行为判据**(跑的是客户端真正引用的那一份,不是复制品)。
* · **来源**:`.ets` 在本机没有运行时(要编译要设备 ⇒ 见 §6.8 的欠账机制),
* 所以"页面真的调了归一化""坏地址真的没被存下来"只能是**静态**判据。
* ⚠️ **它证明形状,不证明值** —— 别把它读成"用户点一下就好了"。
*
* 与「hdc / 真机」的关系:本机 `hdc list targets` 可用时,这一族应该升级成
* 真机点一次登录(那才是 §7 说的"用户真正会点的那一层")。当前没有设备,
* 所以这里如实标注为静态欠账,不冒充端到端验证。
*/
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, pathToFileURL } from 'node:url';
import { code, prose } from './lib/read.mjs';
/*
* ★ 仓库根从**本文件的位置**推,不许硬编码绝对路径 —— worktree 复核时硬编码会
* 静默读另一棵树并报绿(本仓 2026-09-15 真发生过)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const PROFILE_DIR = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile');
const NET_CFG = join(PROFILE_DIR, 'network_config.json');
/** 被测对象:客户端真正引用的那份逻辑(不是复制品) */
const A = await import(pathToFileURL(join(ETS, 'model/ApiBase.ts')).href);
const read = (rel) => code(join(ETS, rel));
/** 递归列出 ets 树下的 .ets/.ts(扫目录的判据必须能自证"扫到了东西",见 §6) */
function sourceFiles(dir = ETS) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) { out.push(...sourceFiles(p)); continue; }
if (e.name.endsWith('.ets') || e.name.endsWith('.ts')) out.push(p);
}
return out;
}
const rel = (p) => p.slice(ETS.length + 1);
/* ───────────────────────── 值判据:跑真逻辑 ───────────────────────── */
test('★ 归一化:末尾没有 /api/v1 就补上(用户踩的就是这个坑),已有的一字不动(不重复补)', () => {
assert.equal(A.normalizeApiBase('https://mail.jianfgit.xyz'), 'https://mail.jianfgit.xyz/api/v1',
'少了 /api/v1 就会请求到 /auth/login ⇒ 404。**正确修法**:归一化时补上。'
+ '**最常见的错误修法**:在页面里各写一遍 trim/拼接(两处口径必然漂移)。');
assert.equal(A.normalizeApiBase('https://mail.jianfgit.xyz/'), 'https://mail.jianfgit.xyz/api/v1',
'尾斜杠要先去掉再补,否则拼出 `//api/v1`');
assert.equal(A.normalizeApiBase('http://192.168.2.60:8180'), 'http://192.168.2.60:8180/api/v1',
'带端口的形态同样要补');
assert.equal(A.normalizeApiBase(' https://mail.jianfgit.xyz '), 'https://mail.jianfgit.xyz/api/v1',
'首尾空白(粘贴常见)要去掉');
// 反向:已经带 /api/v1 的不许再补一层
for (const v of ['https://mail.jianfgit.xyz/api/v1', 'https://mail.jianfgit.xyz/api/v1/',
'http://10.0.2.2:8180/api/v1']) {
const got = A.normalizeApiBase(v);
assert.ok(!got.includes('/api/v1/api/v1'),
`★ ${v} 被补成了 ${got} —— 重复补前缀是 WebUI 侧真发生过的 bug(请求全 404)`);
assert.equal(got, A.stripTrailingSlashes(v), `${v} 已经带 /api/v1 ⇒ 只允许去尾斜杠,内容不动`);
}
assert.equal(A.normalizeApiBase(''), '', '空串保持空("没填"与"填错"要能分开)');
});
test('★ 去尾斜杠不许吃掉协议里的 //(朴素的 while(endsWith("/")) 会把 https:// 咬成 https:)', () => {
assert.equal(A.stripTrailingSlashes('https://'), 'https://',
'协议分隔符的两个斜杠不是"尾部斜杠" —— 咬掉一个就变成 `https:`,请求直接报 URL 非法');
assert.equal(A.stripTrailingSlashes('https://host///'), 'https://host', '路径尾斜杠该去干净');
assert.equal(A.stripTrailingSlashes(' https://host/ '), 'https://host', '先 trim 再去斜杠');
// 下游守卫:`https://` 这种"协议有了、域名没了"的输入必须被判非法,不能悄悄放过去
assert.equal(A.validateApiBase('https://').ok, false, '`https://` 没有域名 ⇒ 必须判非法');
});
test('★ 校验:合法/非法分得开,非法提示**自带修法**(用户照着那一行就能改对)', () => {
assert.equal(A.validateApiBase('https://mail.jianfgit.xyz/api/v1').ok, true, '标准地址要判合法');
assert.equal(A.validateApiBase('http://192.168.2.60:8180/api/v1').ok, true,
'局域网明文直连是**有意保留的合法通道** ⇒ 不许判非法');
const okOne = A.validateApiBase('https://mail.jianfgit.xyz');
assert.equal(okOne.ok, true, '缺 /api/v1 只是要**补**,不是"非法"');
assert.equal(okOne.base, 'https://mail.jianfgit.xyz/api/v1', 'ok 时 base 必须是归一化后的地址');
assert.equal(okOne.error, '', 'ok 时不该有错误文案');
const badOnes = ['', ' ', 'mail.jianfgit.xyz', 'https://', 'https://mail.jianfgit.xyz /api/v1'];
for (const v of badOnes) {
const r = A.validateApiBase(v);
assert.equal(r.ok, false, `★ ${JSON.stringify(v)} 该判非法`);
assert.ok(r.error.length > 0, `★ ${JSON.stringify(v)} 判非法却没给文案 ⇒ 用户只看到"登录失败"`);
assert.ok(r.error.includes('https://') && r.error.includes('api/v1'),
`★ 提示要**自带修法**(指出目标形状 https://域名/api/v1),实际:${r.error}`);
}
});
test('★ 明文 http 只对"公网"告警;内网/本机地址不许误报(否则局域网直连被吓回去)', () => {
const pub = A.validateApiBase('http://mail.example.com/api/v1');
assert.equal(pub.ok, true, '明文不是非法值(局域网直连要用),只是要提醒');
assert.ok(pub.warning.length > 0, '★ 公网明文 http 必须告警:登录凭据会明文发出去,而用户不会自己想到');
for (const v of ['http://192.168.2.60:8180/api/v1', 'http://10.0.2.2:8180/api/v1',
'http://localhost:8180/api/v1', 'http://127.0.0.1:8180/api/v1', 'https://mail.example.com/api/v1']) {
assert.equal(A.validateApiBase(v).warning, '',
`★ ${v} 不该告警 —— 内网明文 / 已经 https,告警多了就没人看了(这也是登录页那条提示被忽略的方式)`);
}
assert.equal(A.isPrivateHost('172.31.0.1'), true, '172.16-31 段也是内网');
assert.equal(A.isPrivateHost('172.32.0.1'), false, '172.32 已经出了内网段,别把公网当内网');
});
test('★ 404 必须自己写文案(服务端只会回 404 page not found),且提示里点名 /api/v1', () => {
const url = 'https://mail.jianfgit.xyz/auth/login';
const d = A.describeFailure(404, 0, '404 page not found', url);
assert.equal(d.kind, 'http404', '404 要单独分类 —— 它不是"网络不通"');
assert.ok(d.message.includes('/api/v1'),
`★ 404 的真实含义几乎总是"地址少了 /api/v1",文案必须点出来,实际:${d.message}`);
assert.ok(d.message.includes(url), '要把实际请求的 URL 打出来,用户才知道自己填的地址拼成了什么');
const bare = A.describeFailure(404, 0, '', url);
assert.ok(bare.message.includes('/api/v1'), '服务端没给文案时也要说清(不能退化成"HTTP 404")');
});
test('★ 其它 HTTP 状态让服务端的文案说话(回归保护:登录失败的原因不能被通用文案顶掉)', () => {
const d = A.describeFailure(401, 0, '用户名或密码错误', 'u');
assert.equal(d.message, '用户名或密码错误',
'★ 服务端说"用户名或密码错误"比客户端编的任何话都准;用通用文案回退它 = 用户再也看不到失败原因');
assert.equal(d.kind, 'http401', '分类仍要能区分 401');
assert.ok(A.describeFailure(403, 0, '', 'u').message.includes('403'), '没有服务端文案时要给出状态码');
assert.ok(A.describeFailure(500, 0, '', 'u').message.includes('500'), '5xx 同样');
});
test('★ 网络层失败按 SDK 错误码分类(码取自本机 @ohos.net.http.d.ts,不是猜的)', () => {
const cases = [
[2300005, 'dns'], [2300006, 'dns'],
[2300007, 'refused'], [2300028, 'timeout'],
[2300058, 'tls'], [2300059, 'tls'], [2300060, 'tls'], [2300077, 'tls'],
[2300997, 'cleartext'], [2300998, 'blocked-domain'],
[2300003, 'bad-url'], [2300094, 'native-auth']
];
for (const [code, kind] of cases) {
const d = A.describeFailure(0, code, 'raw english', 'https://mail.jianfgit.xyz/api/v1');
assert.equal(d.kind, kind, `错误码 ${code} 该归到 ${kind}`);
assert.ok(d.message.length > 6, `错误码 ${code} 的文案太短:${d.message}`);
assert.ok(!d.message.includes('raw english'),
`★ 错误码 ${code} 把英文原文当人话给出了(用户看不懂):${d.message}`);
}
// 域名解析失败要能指名是哪个域名 —— 否则用户不知道该去查哪一条
const dns = A.describeFailure(0, 2300006, 'Couldn\'t resolve host name', 'https://nope.example/api/v1');
assert.ok(dns.message.includes('nope.example'), '★ 要说清"解析不出哪个域名",否则用户只能猜');
// 明文被禁 / 证书不受信要给可执行的下一步(这两条是自建部署最常见的两个坑)
assert.ok(A.describeFailure(0, 2300997, '', 'http://a/api/v1').message.includes('https://'),
'明文被禁 ⇒ 提示里要有"改用 https://"这个动作');
assert.ok(A.describeFailure(0, 2300060, '', 'https://a/api/v1').message.includes('证书'),
'证书不受信 ⇒ 提示要指向证书,而不是笼统的"网络错误"');
// 未知码:不许把原始信息吞掉(日志与用户看到的应该是同一件事)
const unknown = A.describeFailure(0, 12345, 'weird failure', 'https://a/api/v1');
assert.equal(unknown.kind, 'network', '未知码归到 network');
assert.ok(unknown.message.includes('12345') && unknown.message.includes('weird failure'),
'★ 未知错误码要把码与原文带出来 —— 否则现场没线索,只能让用户复现');
});
test('★ hostOf:去协议/路径/端口(失败文案要靠它指名地址)', () => {
assert.equal(A.hostOf('https://mail.jianfgit.xyz/api/v1'), 'mail.jianfgit.xyz');
assert.equal(A.hostOf('http://192.168.2.60:8180/api/v1'), '192.168.2.60', '端口不是主机名的一部分');
assert.equal(A.hostOf('http://[::1]:8080/x'), '[::1]', '方括号 IPv6 不能按第一个冒号切');
});
/* ───────────────────────── 来源判据:形状 + 接线 ───────────────────────── */
test('★ 默认地址自己就必须是"能通的那一个":https + 完整 /api/v1(把源码里的值喂给真逻辑判)', () => {
const cfg = read('common/Config.ets');
const m = /DEFAULT_API_BASE: string = '([^']+)'/.exec(cfg);
assert.ok(m, 'Config.ets 里要能找到 DEFAULT_API_BASE 的字面量');
const value = m[1];
assert.ok(value.startsWith('https://'),
`★ 默认地址是 ${value} —— 明文默认会让"连不上"变成一个用户无从判断的状态;`
+ '公网用 https(域名解析到同一台机,内网走 https 也到得了)');
assert.equal(A.normalizeApiBase(value), value,
'★ 默认地址必须**已经**是归一化后的形态(少了 /api/v1 的默认值 = 开箱即 404)');
/*
* ★★ 2026-09-21 新增(用户:「为什么现在登陆页面默认填写我们的服务器地址?
* 不应该是 example 地址吗」)。
*
* 这条原先只钉"https + 带 /api/v1" —— 而 `https://mail.jianfgit.xyz/api/v1`
* **两条都满足** ⇒ 判据全绿,默认值是开发者自己的生产域名这件事**没人管**。
*
* 判据形状的问题:它验的是"格式对不对",而用户报的是"**值是谁的**"。
* 格式判据抓不到"语义事故"—— 这个值可以完全合法却仍然是错的。
*
* ⇒ 加一条**语义**判据:出厂默认不许是**任何人的真实服务器**。
* 判法是"它必须落在保留域名里"(RFC 2606:`example.com/net/org` 与 `.test/.invalid/.example`)。
* 这样既允许 `example.com`,又挡住把任何真实域名写成默认。
*
* ★ 为什么不留空串:见 `Config.ets` 那段注释 —— 字段必须填对,
* 留空用户不知道格式;而预填一个**看起来能用**的真域名更坏
* (用户会以为"直接登录就行")。
*/
const RESERVED = /^https:\/\/([a-z0-9-]+\.)*(example\.(com|net|org)|example|test|invalid)\/api\/v1$/;
assert.match(value, RESERVED,
`★ 出厂默认是 ${value} —— 那是**某一个真实服务器**的地址。`
+ '通用客户端把某个人的后端写成默认值,等于宣称"本产品只有一个后端";'
+ '且用户会以为"直接登录就行",而连的其实是别人的机器。'
+ '默认值必须是**保留域名**(RFC 2606 的 example.com/test/invalid),'
+ '与实际地址区分开。');
/*
* 提示文案也不许出现真实域名:用户报的正是"**填给我看的示例**也是那个域名"
* (校验失败时那 5 条 error 全在教用户填同一个生产域名)。
* 注释里的历史取证**不算**(那是"当时发生了什么"的记录)—— 所以剥注释再判。
*/
/* ★ 用本文件既有的 `read(rel)`(它已经拼好 ETS 前缀)——
第一版我写了 `code('model/ApiBase.ts')`,而 `code()` 是相对 **electron 包**解析的
⇒ `ENOENT .../client/electron/model/ApiBase.ts`。判据自己崩了。 */
const apiBaseCode = read('model/ApiBase.ts');
const leaked = [...apiBaseCode.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)]
.map(m2 => m2[1])
.filter(h => !RESERVED.test('https://' + h + '/api/v1') && !/^(127\.|10\.|192\.168\.|localhost|\[::1\])/.test(h));
assert.deepEqual(leaked, [],
`★ 代码(非注释)里出现了真实域名:${[...new Set(leaked)].join(', ')}。`
+ '面向用户的**提示文案**必须用示例域名 —— 用户是照着它填的。'
+ '(`Config.ets` 里那句"原来是什么"属于历史取证,在注释里,不受本条约束。)');
const emu = /EMULATOR_HOST_BASE: string = '([^']+)'/.exec(cfg);
assert.ok(emu && A.normalizeApiBase(emu[1]) === emu[1],
'模拟器备用地址也要带 /api/v1(它走的同一个拼接逻辑)');
});
test('★ 归一化的唯一闸口:ApiClient 的 init 与 setBase 都经过 normalizeApiBase', () => {
const src = read('api/ApiClient.ets');
const setBaseIdx = src.indexOf('setBase(base: string)');
assert.ok(setBaseIdx > 0, 'ApiClient 要有 setBase');
const setBaseBody = src.slice(setBaseIdx, src.indexOf('getToken()', setBaseIdx) > 0
? src.indexOf('getToken()', setBaseIdx) : setBaseIdx + 400);
assert.ok(/this\.apiBase = normalizeApiBase\(/.test(setBaseBody),
'★ setBase 直接赋值 = 登录页/设置页/多账号切换三条路各自漏;'
+ '**正确修法**:在这里归一化(一个闸口)。');
assert.ok(/this\.apiBase = normalizeApiBase\(stored\)/.test(src),
'★ init 读 preferences 时也要归一化 —— 老装机里已经躺着一条少了 /api/v1 的坏地址,'
+ '光改默认值救不了它(**最常见的错误修法**:只改 DEFAULT_API_BASE 就交差)');
});
test('★ 两个"用户手填地址"的入口都校验,且坏地址**不落库**(顺序即含义)', () => {
const login = read('pages/LoginPage.ets');
/*
* ★ 必须切出**方法体**再判(变异验证抓出来的):
* 第一版我只判了整个文件里有 `applyServerAddr()` —— 而**删掉 doLogin 里的那句调用**
* 依然全绿(方法**声明**里就有这个字串)。那正是 §6.7 说的"判了形状、没判路径":
* 校验方法写得好好的、就是没人调,等于没做。
*/
const doLoginIdx = login.indexOf('async doLogin()');
assert.ok(doLoginIdx > 0, 'LoginPage 要有 doLogin');
const nextMember = login.indexOf('applyServerAddr(): boolean', doLoginIdx);
const doLoginBody = login.slice(doLoginIdx, nextMember > 0 ? nextMember : login.indexOf('build()', doLoginIdx));
assert.ok(/if \(!this\.applyServerAddr\(\)\)/.test(doLoginBody),
'★ doLogin 里没有"地址非法就 return"的守卫 ⇒ 用户填的坏地址会直接拿去发请求,'
+ '而且**还会被持久化**(**正确修法**:在 setBase/persistBase 之前先 applyServerAddr() 并早退)');
const guardIdx = doLoginBody.indexOf('applyServerAddr()');
const persistIdx = doLoginBody.indexOf('persistBase');
assert.ok(persistIdx > 0, '登录页仍要持久化地址');
assert.ok(guardIdx < persistIdx,
'★ 校验必须在 persist 之前 —— 顺序反了就是把坏地址存进去,下次启动带着它"无法连接"把用户锁在外面');
assert.ok(!/while\s*\(v\.length > 0 && v\.endsWith\('\/'\)\)/.test(login),
'★ 登录页不许再自己写一份"去尾斜杠"(同一个事实两份实现必然漂移)');
// 闸口本身:applyServerAddr 必须真的调 validateApiBase(否则守卫是个空动作、永远放行)
const applyIdx = login.indexOf('applyServerAddr(): boolean');
const applyBody = login.slice(applyIdx, login.indexOf('build()', applyIdx));
assert.ok(/validateApiBase\(/.test(applyBody),
'★ applyServerAddr 里没有 validateApiBase ⇒ 那个守卫是空的(**最常见的错误修法**:'
+ '为了让判据过而只保留一个同名方法,里面什么都不验)');
const settings = read('pages/SettingsPage.ets');
const sValidateIdx = settings.indexOf('validateApiBase');
const addIdx = settings.indexOf('manager.addAccount(');
assert.ok(sValidateIdx > 0, '★ 添加账号对话框同样要校验(它是第二个手填地址的入口)');
assert.ok(addIdx > sValidateIdx,
'★ 校验必须在 addAccount 之前 —— 多账号库直接喂 SseService(base + /events/stream),'
+ '坏地址存进去等于这条账号的实时通道永久连不上,界面上还看不出来');
assert.ok(!/normalizeServer\(/.test(settings), '设置页自己的 normalizeServer 应已删除(改走共用逻辑)');
const client = read('api/ApiClient.ets');
assert.ok(/nativeCode: number = 0/.test(client) && /ApiError\(0, failure\.message, nativeCode\)/.test(client),
'★ 网络层失败要把 BusinessError.code 带进 ApiError(没有它就无法分类成人话)');
});
test('★ 内网明文通道:network_config.json 就在文档规定的位置、按文档结构开明文白名单', () => {
assert.ok(existsSync(NET_CFG),
`★ 缺 ${NET_CFG}。**正确修法**:放在 resources/base/profile/network_config.json`
+ '(官方文档《使用HTTP访问网络·明文HTTP访问权限配置说明》给的就是这个固定位置与文件名,'
+ '**不需要**在 module.json5 里写引用)。');
let cfg = null;
try {
/* ★ 走 read.mjs 的具名入口,不裸用 readFileSync(criteria-hygiene 第 2 条)。
* 这里读的是 JSON **配置**(不是代码),用 `prose()` = 原文,语义正对。 */
cfg = JSON.parse(prose(NET_CFG));
} catch (e) {
assert.fail(`network_config.json 不是合法 JSON(会被打进包里但读不出来):${e.message}`);
}
const sec = cfg['network-security-config'];
assert.ok(sec && Array.isArray(sec['domain-config']) && sec['domain-config'].length > 0,
'★ 结构要是 network-security-config.domain-config[](文档给的那一套键;'
+ 'Android 那套 cleartextTrafficPermitted 在 base-config 下的写法也支持,但域名白名单用的就是这个形状)');
const allowed = sec['domain-config'][0].cleartextTrafficPermitted;
assert.equal(allowed, true,
'★ 内网白名单必须真的允许明文,否则局域网直连会被系统直接拒掉(错误码 2300997)');
const names = (sec['domain-config'][0].domains || []).map((d) => d.name);
assert.ok(names.includes('192.168.2.60'),
`★ 白名单里要有服务端所在的内网地址(当前:${JSON.stringify(names)})`);
assert.ok(names.includes('10.0.2.2'), '模拟器 NAT 地址也要在里面(联调那条通道)');
});
test('★ 归一化只有一份实现:全仓没有第二处自己拼 /api/v1,也没有第二处去尾斜杠', () => {
const files = sourceFiles();
// §6:扫目录的判据要能自证"扫到了东西"(改名/只扫一个子目录会让它变成空判据而全绿)
assert.ok(files.length >= 15, `只扫到 ${files.length} 个源文件,扫描范围不对(应当 ≥15)`);
for (const sub of ['pages', 'common', 'model', 'api']) {
assert.ok(files.some((f) => rel(f).startsWith(sub + '/')), `扫描范围漏了 ${sub}/`);
}
const ALLOW = [{ file: 'model/ApiBase.ts', why: '归一化的唯一实现(其他文件只许引用它)' }];
const appendRe = /(\+\s*['"]\/api\/v1['"])|(['"]\/api\/v1['"]\s*\+)/;
const trimRe = /while\s*\([^)]*endsWith\(\s*'\/'\s*\)/;
const bad = [];
for (const f of files) {
if (ALLOW.some((a) => rel(f) === a.file)) continue;
const src = code(f);
if (appendRe.test(src)) bad.push(`${rel(f)}:自己拼接 '/api/v1'`);
if (trimRe.test(src)) bad.push(`${rel(f)}:自己写"去尾斜杠"循环`);
}
assert.deepEqual(bad, [],
`★ 归一化出现了第二实现(漂移的起点):\n ${bad.join('\n ')}\n`
+ ' **正确修法**:import { normalizeApiBase } from \'…/model/ApiBase\' 用它;'
+ ' **最常见的错误修法**:把这条判据的 ALLOW 加上自己的文件(那等于把"只有一份"废掉)。');
});