**现象**: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 记的方向一致。
98 lines
4.7 KiB
JavaScript
98 lines
4.7 KiB
JavaScript
/**
|
||
* 收件箱兜底轮询: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 里');
|
||
}); |