Files
MailUI4Agents/client/electron/test/harmony-push.test.mjs
JianFeeeee 549836193d 跨端: fix(推送客户端) 热启点通知**不跳转** —— 真机实测逼出来的那半(只写格子没人读)
这是"收尾"里**静态判据看不出来**的那一类,只有设备能抓住。

## 实测经过(模拟器,可复核)

只写 `PendingRoute` 的那版(上一个提交 423ff9f):

    aa force-stop → aa start --ps data '{…open_mail…}'   ⇒ render MailDetailDestination ✓ 冷启能跳
    (应用已在运行)aa start --ps data '{…open_mail…}'   ⇒ **0 次**              ✗ 热启不跳

根因:冷启时页面**刚挂载**,`aboutToAppear` 会读那个静态格子;
热启时页面**早就挂载完**了、`aboutToAppear` 不会重跑 ⇒ 格子写得进去、**没人读**。
症状正是"点了通知,App 弹到前台,停在列表页" —— 而这恰恰是推送**最常见**的用法
(App 在后台,用户点通知回来看那封信)。

## 修法:补"事件发生时就交出去"的那条路

`PushService.deliverRoute(route)`:**有人监听就当场交出去,没人监听才留在格子里**。
两条路都要留着,因为两种启动各走一条(冷启没人监听、热启有人监听):

- `EntryAbility.onCreate` / `onNewWant` 都走 `deliverRoute`(两条启动方式共用一条投递路径);
- `CommPage.aboutToAppear` 注册监听、`aboutToDisappear` 摘除
  (不摘会叫醒已销毁的页面);
- 冷启与热启**共用同一个落点** `navigateToRoute`(各写一遍必然漏改一处)。

不用页面生命周期兜(`onPageShow` 之类):那会在"用户手动返回列表"时反复触发跳转,
而这里要的是"事件发生的那一刻"。

## 验证

真机(模拟器,装新包后实测):
- 热启 ⇒ `render current custom node: MailDetailDestination` ✓(修之前 0 次)
- 冷启回归 ⇒ 仍然 1 次 ✓(没被这次改动破坏)
- 截图硬证:详情页(返回箭头 + 详情窗格);"加载失败"是我塞的假 mail_id
  (`WARM456`)的**正确**后果 —— 说明确实带着那个 id 去取了

判据:`harmony-push.test.mjs` 18 → 19 条,新增「接线④:热启要能跳」,
**变异验证过两个方向**(deliverRoute 退回"只写格子" ⇒ 红;删掉监听器注册 ⇒ 红)。
它单独存在的理由:接线③那种"有人读"的检查**会放它过去** ——
`aboutToAppear` 里读 pendingRoute 完全满足③,而热启路径是死的。
2026-09-17 19:08:27 +08:00

296 lines
20 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'], '可选字段为空就不放(空串虽合法,但不放更不容易踩校验)');
});
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');
});