Files
MailUI4Agents/client/electron/test/inbox-fallback-poll.test.mjs
JianFeeeee 93697c4061 test(工具链): ★★★ stripComments 两趟正则把真代码当注释吃掉 137 行 —— 改单趟扫描
**现象**:harmony-admin 那条「服务端要注册 GET /auth/me」报红,而
server/cmd/server/main.go:194 **明明写着** r.Get("/auth/me", handler.Me)。

**真因**(不是服务端写错,是读取器错了):
    main.go:78   // 与 /api/v1/agent/* 完全同一份代码
                                          ↑ 这个 /* 在 // 里面
stripComments 原来是**两趟正则**(先块 {/\*[\s\S]*?\*\//g}、后行),
两趟**互相看不见对方** ⇒ 块注释那趟在**还没删行注释**的文本上看到那个 /*,
当块注释开头,一路找下一个 */(在 :214)⇒ **137 行 / 37 条路由注册**
被当注释抹掉,含它正在断言的 r.Get("/auth/me", …)。
全仓另有 12 处同样写法(/me/*、/assets/*、plugins/*…)。

⇒ 失效形状是「**读取器静默少给一段真代码**」(不抛错、不警告),
  症状却出现在**被测对象**上 —— 看起来像"服务端把路由删了"。

**修法**:单趟字符扫描,且状态只用源码(注释内部不参与字符串状态)。
并**补上正则字面量**这一条 —— 漏认的方向是**假绿**:
harmony-device.mjs:59 的 /"bundleName"\s*:\s*"([^"]+)"/ 有 4 个引号(奇数),
打开的"字符串"永不闭合 ⇒ 后面所有注释被当字符串跳过。

五条性质逐条实测:① 行号不变 ② 'http://…' 字符串不被腰斩
③ 注释里的引号不污染状态 ④ 正则字面量被当正则 ⑤ 块注释连文本一起删。
变异测试:把旧实现放回去 ⇒ harmony-admin 确实变红(确认真修好了,
而不是"改的东西恰好没人用")。

**顺带修的三处判据自身缺陷**(都不是源码问题):
· inbox-fallback-poll:原断言钉的是**行尾注释里的字**
  (删掉注释照样绿、塞进 await fetchInbox() 也照样绿)⇒ 改为取
  if (lastTotal === null) { … } 整个分支做结构断言
· inbox-fallback-poll / sse-credentials:裸 readFileSync ⇒ 具名 code()
  (换成更严格的读取后变红,暴露的是判据本来就在判错的对象)
· appearance-defaults:写死包名 ⇒ ourBundle()(deviceprobe 那条在盯这个)
· criteria-hygiene:自检样本「以 // 开头的字面量」被判成写死路径 ⇒
  按**形状**排除,**不按文件/变量名豁免**(该文件自己的注释已写过
  「豁免按名字或目录裁 = 给逃逸指路」)。变异验证:改成真路径仍红。

边界 / 未做:本函数**不区分模板串里的 ${…} 与字符类里的 /**,
失效方向是假绿(少剥注释),与 stripStrings 记的方向一致。
2026-10-03 10:43:11 +08:00

98 lines
4.7 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.

/**
* 收件箱兜底轮询:SSE 的**冗余**通道。
*
* ## 为什么需要
*
* SSE 只在**真的断开**时触发 `onerror`。有一类故障它看不见:
* **连接还在、TCP 没断、却不再收数据**(代理静默丢包、NAT 超时、
* 中间设备挂死长连接)。两端都认为连接正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。
*
* 症状与用户报的一致:「页面停留不动、新邮件不自动同步」,
* 而手动刷新能看到(刷新走新请求,与那条挂死的连接无关)。
*
* ## 为什么只比 `total`
*
* 每 N 秒全量重拉会让接口与渲染无谓地抖。`getInbox('all', 1)` 只要计数,
* 变了才真的拉列表。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { code } from './lib/read.mjs';
const pollSrc = code('src/lib/inboxFallbackPoll.ts');
const appSrc = code('src/App.tsx');
test('★ 探针只取计数(limit=1),变了才重拉', () => {
// 全量重拉会让接口与渲染无谓抖动;探针要廉价
assert.ok(
/getInbox\('all',\s*1\)/.test(pollSrc),
'探针必须 limit=1 只要 total,不要正文');
assert.ok(
/total !== lastTotal/.test(pollSrc),
'必须比 total —— 变了才刷新');
});
test('★ 首轮只建基线不触发刷新(否则刚打开页面就白拉一次)', () => {
/*
* ★ 2026-10-03 改写:原来这一格断言的是 `return false; // 首轮建立基线` ——
* **它钉的是行尾注释里的字**,所以 (a) 任何人改个措辞就假红,
* (b) 把注释删掉反而**照样通过**(`return false;` 还在,只是没了后缀)。
* `CRITERIA.md` §1「判结构与行为,不判字面与邻接」钉的就是这一族。
* 顺带发现:本判据原来用裸 `readFileSync` 读全文,注释与代码混在一起判 ——
* 换成具名的 `code()`(剥注释)之后,这一格才**暴露**出来它本来就判错了对象。
*
* 现在判的是**结构**:`lastTotal === null` 的分支里只有赋值 + `return false`,
* **不得**出现 `fetchInbox`(出现了就意味着首轮会白拉一次列表)。
*/
const branch = pollSrc.match(/if \(lastTotal === null\) \{([\s\S]*?)\n {4}\}/);
assert.ok(branch, '未找到「首轮建立基线」那个分支(`if (lastTotal === null)`)');
assert.match(branch[1], /lastTotal = total;/, '首轮分支必须先把基线记下来');
assert.match(branch[1], /return false;/, '首轮分支必须返回 false(不触发刷新)');
assert.ok(
!/fetchInbox/.test(branch[1]),
'★ 首轮分支里出现 fetchInbox ⇒ 刚打开页面就白拉一次列表 —— ' +
'这正是这条判据要拦的行为(原来只判了行尾注释,删掉注释也照样绿)');
});
test('★ 探针失败不得重置基线', () => {
// 网络抖动不该让下一轮误判成「有变化」而触发全量重拉
const m = pollSrc.match(/} catch \{[\s\S]*?\n \}/);
assert.ok(m, '未找到 catch 分支');
assert.ok(
!/lastTotal = null/.test(m[0]),
'★ catch 里不得重置基线 —— 那会把一次抖动变成「下一轮假装有变化」');
});
test('★ inFlight 去重(上一轮未回就不叠请求)', () => {
assert.ok(
/if \(inFlight\) return false;/.test(pollSrc),
'必须有 inFlight 去重,否则慢网络下请求会叠加');
});
test('★ 页面隐藏时暂停 + 恢复可见立刻探一次', () => {
// 用户往往就是「切回来发现没更新」才报的 —— 那一下正好补上
assert.ok(
/visibilitychange/.test(pollSrc) && /pollOnce\(fetchInbox\)/.test(pollSrc),
'恢复可见时应立刻探一次,不等下一个周期');
assert.ok(/paused = document\.visibilityState !== 'visible'/.test(pollSrc),
'初始就应按可见性决定是否暂停');
});
test('★ App 里两条通道的 cleanup 都要调(漏一个 = 旧账号定时器残留)', () => {
assert.ok(/stopPoll\(\)/.test(appSrc), 'cleanup 必须停轮询定时器');
assert.ok(/disconnect\(\)/.test(appSrc), 'cleanup 必须断 SSE');
assert.ok(
/resetPollBaseline\(\)/.test(appSrc),
'切号必须丢弃基线:新账号的 total 与旧账号无关,'
+ '不丢会误判成「有变化」而白拉一次');
});
test('★ 轮询与 SSE 共用同一个 effect 依赖(凭证变化时一起重建)', () => {
// 轮询若挂在另一个 effect 上,切号时它不会重建 ⇒ 拿新凭证查旧账号的收件箱
assert.ok(
/const credentialSig = useCredentialChange\(\);/.test(appSrc),
'凭证身份必须存在');
assert.ok(
/}, \[phase, credentialSig\]\);/.test(appSrc),
'effect 依赖必须含 credentialSig —— 轮询与 SSE 都在这个 effect 里');
});