Files
MailUI4Agents/client/electron/test/harmony-push.test.mjs
JianFeeeee 9404f98bde 跨端: fix(推送客户端) session_id 传的是**服务器地址** —— 一个"不报错"的假数据
这条不是别人报的 bug,是我自己在做上一封收尾时写下的,今天读自己的代码才发现。

## 错在哪

`reportToken` 里:

    const sessionId: string = this.account.getActiveAccount() === null
      ? ''
      : this.account.getActiveAccount()!.server;      ← AccountInfo.server = **服务器地址**

`AccountInfo.server` 的值长这样:`https://mail.jianfgit.xyz/api/v1`。
而服务端对这个字段的语义是「客户端**当前所在的会话**:点通知要回到那条会话里的那封信」
(`server/internal/handler/push.go`),类型上是会话 UUID(`push_tokens.session_id`)。

## ★ 为什么它能一路活到今天:因为它**不报错**

两件事叠在一起,让它完全无声:

1. 服务端对这个字段**不做格式校验**(只 `TrimSpace`,并且明确允许为空);
2. 服务端当前**只写不读** —— 投递时用的是**邮件自己的** `n.SessionID`
   (`notify/mail.go` 构造 `push.NewMail{SessionID: m.SessionID}`),
   `dispatch` 只用 `Provider`/`Token` 两个字段。

⇒ 它不会 400、不会影响收信、不会出现在任何日志里,
只会在 `push_tokens` 里静静存一条**假的**会话 id。
等哪天真按这个字段路由(它存在的**唯一目的**就是那个),
人会莫名其妙被送到别的会话去 —— 而那时没人会想到根因在这个字段。
**"不会立刻炸"正是这类错最危险的地方:它不报错,它让数据开始说谎。**

## 修法:如实传空,不编一个

这一步的时机**决定了它必然为空**:`reportToken` 只在**登录成功**与**换账号**时跑,
那时用户还没打开任何会话;客户端也**根本没有"当前会话"这个状态**可读
(我确认过:`MainPage` 不记当前会话,全仓没有 `currentSession` 之类的东西)。

而服务端对这个字段**明确允许空**:"客户端还没进任何会话,此时通知只带 mail_id"。
⇒ **传空是准确的,传 URL 是错的。** 等客户端真有了"当前会话",再回来补真实 id。

## 判据(19 → 20 条)

新增「session_id 不许拿服务器地址顶替」,钉的是**值的来源**而不是"有没有传",
所以既能挡住"再塞个别的字段顶替",也不妨碍将来补报真实 id(那时这条要改成钉真实 id)。

变异验证两个方向都做了:
- 还原成原 bug(传 `.server`)⇒ `not ok 9` 红;
- 换成 `.username` 顶替(换汤不换药)⇒ 同样红;
- 恢复 ⇒ 20/20 绿,文件逐字还原。

编译侧反向对照照旧:在新代码那行植入必然类型错误 ⇒
`ArkTS Compiler Error: Type 'number' is not assignable to type 'string'.
At File: …/PushService.ets:255:11` ⇒ `BUILD FAILED`(证明这个模块真被编译,
不是"改了没接线所以通过")。

真机:装新包后行为不变仍是静默失败(`push token 取不到(静默,属正常):Illegal application identity.`),
`jscrash`/`FaultLogger` 计数 0 ⇒ 这个修复没有改变失败路径的形状,只改了不再写假数据。
2026-09-17 19:38:15 +08:00

333 lines
22 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.

import test from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { code } from './lib/read.mjs';
/*
推送客户端契约层(pi 邮件 `1f9ff3b4`)。四条不变量里**三条是纯逻辑**,所以不需要设备就能钉:
静默失败、`enabled:false` 是正常态、按 `provider+tail` 比、通知按 `mail_id` 去重。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const HARMONY = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'model');
const C = await import(pathToFileURL(join(HARMONY, 'PushContract.ts')).href);
test('★ 上报决策:空 token 不上报;同账号同 token 不上报;变了才上报', () => {
assert.equal(C.shouldReportToken('', 'acct1', ''), false, '取不到 token 就不许上报(静默跳过)');
const m = C.reportMarker('acct1', 'abc');
assert.equal(C.shouldReportToken(m, 'acct1', 'abc'), false, '没变就不许重复上报(否则每次启动打一次接口)');
assert.equal(C.shouldReportToken(m, 'acct1', 'xyz'), true, 'token 变了要上报');
assert.equal(C.shouldReportToken('', 'acct1', 'xyz'), true, '第一次拿到要上报');
});
/*
★ 账号必须绑进标记(pi 邮件 `2518e1a3` 的服务端事实):同一个 token **换账号登录是"转移"不是并存**,
所以"我登记过没有"的答案**随账号而变**。只按 token 存标记 ⇒ 换账号后**错误跳过上报**,
而那个账号其实没登记过。这条判据钉的是"换账号必然重新上报"这个性质。
*/
test('★ 换账号后必然重新上报(token 转移不是并存 —— 别把登记状态缓存成长期结论)', () => {
const token = 'AAAABBBBCCCC123456';
const m1 = C.reportMarker('acct1', token);
assert.equal(C.shouldReportToken(m1, 'acct1', token), false, '同账号同 token:不重复上报');
assert.equal(C.shouldReportToken(m1, 'acct2', token), true,
'换账号 + 同一个 token ⇒ 必须重新上报(服务端是转移,acct2 其实没登记过)');
assert.notEqual(m1, C.reportMarker('acct2', token), '标记的形状就保证账号变了必然不同(不靠人记得 reset)');
assert.equal(C.shouldReportToken(C.reportMarker('acct2', token), 'acct1', token), true, '切回来也要重新上报');
});
test('★ "登记过没有"只按 provider + tail 比 —— 比全文是"看起来更严、其实永远为假"的写法', () => {
const token = 'AAAABBBBCCCC123456';
const list = [{ provider: 'hms', token_tail: '123456' }];
assert.equal(C.tokenTail(token), '123456', 'tail 取尾 6 位(与服务端同口径)');
assert.equal(C.isRegistered(list, 'hms', token), true, '尾 6 位相同就算登记过');
// 反证:为什么不能比全文 —— 服务端只回 tail,全文永远不等于 tail
assert.notEqual(token, list[0].token_tail, 'GET 只回尾 6 位');
assert.equal(C.isRegistered(list, 'apns', token), false, 'provider 维度必须参与比较');
assert.equal(C.isRegistered(list, 'hms', 'ZZZZZZZZZZZZ123456'), true,
'同尾 6 位即视为同一条 —— 这是服务端给的信息量的上界,不是我们的选择');
assert.equal(C.tokenTail('abc'), 'abc', '短于 6 位时取全文(不补零、不截空)');
});
test('★ 上报结果分类:三种里没有一种是"提示失败"(enabled:false 是正常态)', () => {
assert.equal(C.classifyRegister(true, false), 'ok-enabled');
assert.equal(C.classifyRegister(false, false), 'ok-disabled', 'enabled:false 是正常态 ⇒ 不重试、不提示');
assert.equal(C.classifyRegister(true, true), 'silent-skip', '失败归 silent-skip,即使服务端 enabled=true');
assert.equal(C.classifyRegister(false, true), 'silent-skip');
});
test('★ 通知 data:不满足约定形状就静默忽略(不"尽力打开某个页面")', () => {
const ok = C.parseNotificationData(JSON.stringify({ type: 'new_mail', mail_id: 'm1', session_id: 's1', action: 'open_mail' }));
assert.equal(ok.mail_id, 'm1');
assert.equal(ok.session_id, 's1');
assert.equal(C.parseNotificationData(''), undefined, '空串');
assert.equal(C.parseNotificationData('{不是 json'), undefined, '坏 JSON 不许抛(推送是可选通道)');
assert.equal(C.parseNotificationData(JSON.stringify({ action: 'open_mail' })), undefined, '缺 mail_id');
assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: 'm1', action: 'other' })), undefined, '动作不是 open_mail');
assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: '', action: 'open_mail' })), undefined, '空 mail_id');
});
test('★ 去重台账:服务端无幂等键 ⇒ 重复保护落客户端;且有界', () => {
const led = new C.NotificationLedger(3);
assert.equal(led.shouldHandle('m1'), true, '第一次该处理');
assert.equal(led.shouldHandle('m1'), false, '重复必须丢弃(服务端至多一次、无幂等键)');
assert.equal(led.shouldHandle('m2'), true);
assert.equal(led.shouldHandle(''), false, '空 id 不处理');
assert.equal(led.size(), 2);
led.shouldHandle('m3'); led.shouldHandle('m4');
assert.equal(led.size(), 3, '有界(常驻 pane 不许无限长)');
assert.equal(led.shouldHandle('m1'), true, '最旧的被挤出后可再处理(有界台账的代价,明写在这里)');
});
test('★ 契约层必须保持"无 @ohos 依赖"(否则这些判据跑不了,会退化成必须上设备)', () => {
// 扫 code()(去注释):第一次跑这条时它咬到了**解释这条规则的那行注释** —— 与扫描口径同族的现成例子
const src = code(join(HARMONY, 'PushContract.ts'));
assert.ok(!/@ohos|@kit\./.test(src),
'PushContract.ts 里出现了 @ohos/@kit 依赖 ⇒ 判据将无法用 node 直接跑。' +
'**正确修法**:把平台调用留在 PushService.ets,纯决策留在这里(与 Calendar/Wallpaper 同模式)。' +
'**最常见的错误修法**:把这条断言删掉,让契约层的判据跟着一起失效。');
});
/*
★ 线上形状(pi 邮件 `004983bb`,从 handler/push.go 读的):请求体字段名必须逐字一致
(**未知字段直接 400**,不是静默忽略),provider 只做形状校验、**没有白名单**,
错误是 `{"error"}` 不是 `{"message"}`。这些都能在纯逻辑侧判。
*/
test('★ 请求体只放已知键(未知字段服务端直接 400,拼错会立刻可见)', () => {
const b = C.buildTokenBody('hms', 'tok123', '我的手机', 's1');
assert.deepEqual(Object.keys(b).sort(), ['device_name', 'provider', 'session_id', 'token']);
for (const k of Object.keys(b)) assert.ok(C.PUSH_BODY_KEYS.includes(k), `不许出现未登记的键:${k}`);
const minimal = C.buildTokenBody('hms', 'tok123', '', '');
assert.deepEqual(Object.keys(minimal).sort(), ['provider', 'token'], '可选字段为空就不放(空串虽合法,但不放更不容易踩校验)');
});
/*
* ★ `session_id` 传的必须是**会话 id**,不许拿别的字段顶上。
*
* 起因:`reportToken` 原本写的是 `getActiveAccount()!.server` —— 那是**服务器地址**
* (`AccountInfo.server`,值形如 `https://mail.jianfgit.xyz/api/v1`),不是会话 id。
*
* 为什么它能活到今天:服务端对这个字段**不做格式校验**、当前又**只写不读**
* (投递用的是**邮件自己的** `session_id`),所以它不报错、不影响收信 ——
* 只在库里静静存一条假的会话 id。**"不报错"正是它危险的地方**:等哪天真按它路由,
* 人会莫名其妙被送到别的会话去,而那时没人会想到根因在这个字段。
*
* 而这一步的时机也决定了它**必然为空**:`reportToken` 只在登录成功/换账号时跑,
* 那时用户还没打开任何会话,客户端也没有"当前会话"这个状态可读。
* ⇒ 传空是**准确**的(服务端明确允许空:"客户端还没进任何会话"),传 URL 是**错的**。
*
* 判据钉的是"值的来源",不是"有没有传":所以既能挡住"再塞个别的字段顶替",
* 也不妨碍将来客户端真有了当前会话时补报 —— 那时这条判据要**改成钉真实 id**。
*/
test('★ session_id 不许拿服务器地址顶替(静默写假数据;该空就空)', () => {
const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets'));
// 取值处不许出现 .server(AccountInfo.server = 服务器地址,不是会话 id)
assert.doesNotMatch(svc, /buildTokenBody\([^)]*\.server/,
'buildTokenBody 的 sessionId 实参不许来自 `.server` —— 那是服务器地址。' +
'服务端对 session_id 不做格式校验、也还没读它,所以这错**不会当场报错**,' +
'只会在 push_tokens 里存一条假会话 id;等真按它路由时症状会出现在完全无关的地方。');
// 也不许拿 username/token/accountId 等别的身份字段顶替(换汤不换药)
for (const wrong of ['.username', '.token', '.id']) {
assert.doesNotMatch(svc, new RegExp('buildTokenBody\\([^)]*\\' + wrong + '\\b'),
`session_id 不许用 \`${wrong}\` 顶替 —— 那只是把"传错东西"换了个字段,性质没变。` +
'客户端现在没有"当前会话"这个状态 ⇒ **如实传空**(服务端允许空),别编一个。');
}
// 传空这件事本身要在代码里说得出口(空串,不是 undefined 混过去)
assert.match(svc, /const sessionId: string = '';/,
'session_id 应显式取空串并写明理由(服务端 "允许为空")—— 而不是删掉这个变量、' +
'让它看起来"本来就不需要"(将来要补真实 id 的人会找不到落点)');
});
test('★ provider 只做形状校验、没有白名单 —— 不许硬编码"只有 hms 合法"', () => {
assert.equal(C.isValidProvider('hms'), true);
assert.equal(C.isValidProvider('apns'), true, '服务端没实现的通道也不该变成客户端的 400');
assert.equal(C.isValidProvider('fcm'), true);
assert.equal(C.isValidProvider('HMS'), false, '大写非法(形状规则是小写字母/数字/下划线/连字符)');
assert.equal(C.isValidProvider(''), false, '空非法');
assert.equal(C.isValidProvider('a'.repeat(33)), false, '最长 32');
assert.equal(C.isValidProvider('a'.repeat(32)), true);
assert.equal(C.isValidProvider('has space'), false);
});
test('★ token 形状:空非法、512 上限;形状不合法就不去打注定 400 的请求', () => {
assert.equal(C.isValidToken(''), false);
assert.equal(C.isValidToken('x'.repeat(512)), true);
assert.equal(C.isValidToken('x'.repeat(513)), false);
assert.equal(C.buildTokenBody('hms', '', '', ''), undefined, '空 token ⇒ 组不出请求体(调用侧静默跳过)');
assert.equal(C.buildTokenBody('HMS', 'tok', '', ''), undefined, 'provider 形状非法 ⇒ 同样组不出');
});
test('★ 错误体是 {"error"} 不是 {"message"}(形状不对就不当错误消息用)', () => {
assert.equal(C.parseErrorBody('{"error":"token 非法"}'), 'token 非法');
assert.equal(C.parseErrorBody('{"message":"x"}'), undefined, '{"message"} 是另一种形状 —— 不能当错误消息');
assert.equal(C.parseErrorBody('not json'), undefined);
assert.equal(C.parseErrorBody(''), undefined);
assert.equal(C.parseErrorBody('{"error":""}'), undefined, '空消息等于没有消息');
});
test('★ 注销:deleted:false(本来没登记)不是失败;deleted 不参与分类', () => {
assert.equal(C.classifyUnregister(false, false), 'ok-disabled', '没配凭证 + 没登记过 ⇒ 正常态');
assert.equal(C.classifyUnregister(true, false), 'ok-enabled');
assert.equal(C.classifyUnregister(false, true), 'silent-skip', '真失败才归 silent-skip');
assert.equal(C.classifyUnregister.length, 2, 'deleted 不影响结果 ⇒ 它不该是入参(否则读代码的人会以为它影响结果)');
});
test('★ DELETE 也带 JSON body(不是 query / 不是 path 参数)—— ApiClient 必须支持', () => {
const api = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'ApiClient.ets'));
assert.match(api, /async del<T>\(path: string, bodyObj\?: Object\)/,
'DELETE 端点要 JSON body,而`del`只有 path ⇒ 推送的注销调用会 400/无效。' +
'**正确修法**:给 del 加可选 body(向后兼容)。**最常见的错误修法**:把 token 拼进 URL(那不是线上形状)。');
});
/*
* ★ 取 token 的两个硬前提(2026-09-17 实测根因,pi 邮件线上链路):
*
* 症状:鸿蒙 App 连上了、登录了(服务端收到 GET /me/devices/push-token 200),
* 但**从来没有 POST** —— getToken() 返回空串,reportToken 直接 return。
*
* 根因 ①:module.json5 的 module.metadata **没有配 client_id**。
* Push Kit 必须先读到这个 client_id 才能向华为推送服务器申请 token ——
* 缺了它 getToken() 永远返回空(且不报错,静默)。
* 业界示例(dev.to 的 HarmonyOS Next PushKit 集成)与之互证:
* "获取 token 前需在 module.json5 的 module.metadata 中配置 client_id(来自 AGC)"。
*
* 根因 ②:通知权限申请必须在取 token **之前**。
* 部分设备上通知权限没开时 getToken 会返回空 / 报 1600004。
*
* 这两条都不需要设备就能钉(文件形状),且是最容易悄悄回归的地方。
*/
test('★ 取 token 前提①:module.json5 的 module.metadata 必须配 client_id(缺了 getToken 恒空)', () => {
const mod = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'module.json5'));
// module 级 metadata(不是 abilities/extensionAbilities 里的)
assert.match(mod, /"metadata":\s*\[\s*\{\s*"name":\s*"client_id"\s*,\s*"value":\s*"[0-9]+"\s*\}/,
'module.metadata 里必须有 {"name":"client_id","value":"<数字>"}。' +
'这是 Push Kit 申请 token 的硬前提;缺了它 getToken() 静默返回空串 —— ' +
'症状是"App 能连服务端、能 GET,但永远不 POST token"。');
// client_id 的值要跟 agconnect-services.json 里的一致(不是随手编一个数)
const agc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'agconnect-services.json'));
const m = agc.match(/"client_id"\s*:\s*"(\d+)"/);
assert.ok(m, 'agconnect-services.json 里要能找到 client_id');
assert.ok(mod.includes(`"value": "${m[1]}"`),
`module.json5 的 client_id 必须等于 agconnect-services.json 里的 ${m[1]}(两处不一致时 Push Kit 认不出应用)`);
});
test('★ 取 token 前提②:reportToken 里 requestEnableNotification 在 getToken 之前', () => {
const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets'));
const lines = svc.split('\n');
const start = lines.findIndex(l => /async reportToken\(/.test(l));
assert.ok(start > 0, '要能找到 reportToken');
const body = lines.slice(start, start + 60).join('\n');
const permAt = body.indexOf('requestEnableNotification');
const tokenAt = body.indexOf('this.getToken()');
assert.ok(permAt > 0, 'reportToken 里要有 requestEnableNotification');
assert.ok(tokenAt > 0, 'reportToken 里要有 getToken');
assert.ok(permAt < tokenAt,
'通知权限申请必须在 getToken **之前** —— 部分设备上权限未开时 getToken 返回空 / 报 1600004(2026-09-17 实测)');
});
/*
* ★★ 三条**接线**判据(2026-09-17 收尾,pi 邮件 `66bbd929` §三 分工给我那两处 + 我加的第三处)。
*
* 为什么要有它们:`PushService.reportToken` 与 `PushService.pendingRoute` 都**写好了**,
* 但"写好了"和"有人调用/有人读"是两件事 —— 而**编译通过不覆盖后者**:
* ArkTS 只编译**可达**模块,一个没人 import 的新文件里放必然报错的类型错误,
* `assembleHap` 照样 BUILD SUCCESSFUL(pi 与我各自复现过这个反向对照)。
*
* 所以这三条钉的是**边**,不是**点**:谁调用 `reportToken`、谁消费 `pendingRoute`。
* 它们都是**静态**判据(读形状),因为"点通知真的跳过去"只有设备能判;
* 设备那半的到期前提记在 `docs/DEBTS.json` 的 static-criteria 里。
*/
test('★ 接线①:登录成功必须补报(启动那次在登录之前 ⇒ 没 token ⇒ 静默 401,覆盖不到登录)', () => {
const login = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'LoginPage.ets'));
/*
* 三条"登录"路径,**每条都要补报**:
* ① aboutToAppear 的快速路径(已有账号直接进主界面)—— 老用户走这条;
* ② tryRestore(旧 token 恢复);
* ③ doLogin(手填账号密码 / 密钥)。
* 只覆盖 doLogin 是那种"看起来做了、漏掉最常走的那条"的写法,所以这里数**调用点个数**。
*/
const calls = (login.match(/this\.reportPushToken\(/g) || []).length;
assert.equal(calls, 3,
`LoginPage 里补报调用应恰好 3 处(快速路径 / tryRestore / doLogin 各一处),实得 ${calls} 处。` +
' 少了快速路径 = 老用户永远不补报(最需要推送的那批);多了则要问清楚是哪条路径。');
assert.match(login, /PushService\.getInstance\(ctx as common\.UIAbilityContext\)/,
'补报要走 PushService 单例(与 SettingsPage 同一形状)');
assert.match(login, /\.catch\(\(err: Object\)/,
'补报必须**吞掉失败** —— 推送是可选通道,不能因它弹错或拖住登录跳转');
assert.match(login, /push\.reportToken\(c\)|push\.reportToken\(client\)/,
'要真的把 ApiClient 传进 reportToken(带上刚登录的凭证)');
});
test('★ 接线②:换账号后必须补报(同一 token 换账号是"转移"⇒ 新账号其实没登记过)', () => {
const settings = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'SettingsPage.ets'));
const lines = settings.split('\n');
const start = lines.findIndex(l => /async switchTo\(/.test(l));
assert.ok(start > 0, '要能找到 SettingsPage.switchTo');
const body = lines.slice(start, start + 45).join('\n');
assert.match(body, /reportToken\(/,
'switchTo 里必须补报一次:服务端里同一 token 换账号是**转移**(不是并存),' +
'不补报的症状是"切完账号,通知仍推给上一个账号"。');
assert.match(body, /PushService\.getInstance/,
'switchTo 里要走 PushService 单例');
});
test('★ 接线③:pendingRoute 必须有人**消费**(EntryAbility 只写不读 ⇒ 点通知停列表页)', () => {
const main = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets'));
const ent = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'entryability', 'EntryAbility.ets'));
// 写侧:ability 解析 want(冷启 onCreate + 热启 onNewWant),两条都要交出去
const writes = (ent.match(/PushService\.deliverRoute\(route\)/g) || []).length;
assert.equal(writes, 2,
'EntryAbility 的 onCreate 与 onNewWant 都要 deliverRoute(冷启漏了就只有热启能跳,反之亦然)');
// 读侧:必须有页面真的读它,并**清空**它
assert.match(main, /PushService\.pendingRoute/,
'MainPage 必须读 pendingRoute —— 只有 EntryAbility 写、没人读,等于"点通知只拉起 App"');
assert.match(main, /PushService\.pendingRoute = undefined/,
'消费后必须清空:它是"待处理"不是"当前页",不清的话返回列表再进这页会被反复跳走');
// 消费端要真的落到邮件详情(而不是只把变量读出来丢掉)
assert.match(main, /this\.openMail\(route\.mailId/,
'消费 pendingRoute 要落到邮件详情(openMail 往 navPathStack 压详情路由)');
});
/*
* ★★ 接线④:**热启**(应用已在运行)必须也能跳 —— 这一条是真机实测逼出来的。
*
* 实测经过(2026-09-17,模拟器):只写 `pendingRoute` 的版本,
* 冷启能跳(页面刚挂载 ⇒ `aboutToAppear` 会读),
* **热启一次都不跳**(页面早挂载完、`aboutToAppear` 不会重跑,格子没人读)。
* 症状:点了通知,App 弹到前台,停在列表 —— 而这**恰恰是推送最常见的使用场景**
* (App 在后台,用户点通知回来看那封信)。
*
* 静态判据单独钉这一条,是因为接线③那种"有人读"的检查**会放它过去**:
* `aboutToAppear` 里读 pendingRoute 完全满足③,但热启路径是死的。
* ⇒ 必须要有"**事件发生时就交出去**"的那条路(监听器),而不只是"页面起来时读一次"。
*/
test('★ 接线④:热启要能跳(页面已挂载 ⇒ 只写格子没人读 ⇒ 必须有人被"叫醒")', () => {
const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets'));
const main = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets'));
// ① deliverRoute:有人监听就**当场交出去**,没人监听才留在格子里
const start = svc.indexOf('static deliverRoute(');
assert.ok(start > 0, 'PushService 要有 deliverRoute(两条启动方式共用一条投递路径)');
const body = svc.slice(start, start + 420);
assert.match(body, /routeListener/,
'deliverRoute 必须先看有没有监听者 —— 没有这一半,热启就是死的');
assert.match(body, /fn\(route\)|routeListener\(route\)/,
'有人监听时要**当场调用**它(这才是"叫醒")');
assert.match(body, /PushService\.pendingRoute = route/,
'没人监听(冷启)时才落进格子 —— 两半都要在,少一半就废掉一种启动方式');
// ② 消费侧:注册 + 摘除
assert.match(main, /PushService\.setRouteListener\(/,
'MainPage 要注册监听器(只在 aboutToAppear 读一次是不够的)');
assert.match(main, /PushService\.clearRouteListener\(\)/,
'aboutToDisappear 要摘掉监听器 —— 否则会叫醒已销毁的页面');
// ③ 两条路必须**共用同一个落点**,不许各写一遍(写两遍必然漏改一处)
// 调用点恰好 2 处:热启的监听器回调 + 冷启的 consumePendingRoute。
const navCalls = (main.match(/this\.navigateToRoute\(/g) || []).length;
assert.equal(navCalls, 2,
`冷启消费与热启监听都该走 navigateToRoute(实得 ${navCalls} 处调用),` +
'两条启动路径各写一遍跳转逻辑时,改一处必漏另一处');
assert.match(main, /private navigateToRoute\(route: PushRoute\): void \{/,
'共用落点要实现成 navigateToRoute');
});