## ① 页面间转场(`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 那条同一个诚实口径。
349 lines
23 KiB
JavaScript
349 lines
23 KiB
JavaScript
/*
|
||
* 鸿蒙客户端「连不上服务器」那一族(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 加上自己的文件(那等于把"只有一份"废掉)。');
|
||
});
|