这条不是别人报的 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 ⇒ 这个修复没有改变失败路径的形状,只改了不再写假数据。
333 lines
22 KiB
JavaScript
333 lines
22 KiB
JavaScript
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');
|
||
});
|
||
|
||
|