**现象**: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 记的方向一致。
103 lines
5.6 KiB
JavaScript
103 lines
5.6 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { code } from './lib/read.mjs';
|
||
|
||
// ★ 服务端在**仓库根**(`server/`),而 `code()` 的相对基准是**包目录**
|
||
// (`test/lib/read.mjs` 的 `PKG_ROOT`)⇒ 跨到包外必须给绝对路径。
|
||
// 绝对路径由 `code()` 原样透传(见该文件 `abspath` 的注释),所以这样是安全的。
|
||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||
const sse = code('src/api/sse.ts');
|
||
const app = code('src/App.tsx');
|
||
// 服务端在另一个仓(../../../../server),读它是为了钉「两侧同步」这条 ——
|
||
// 只改客户端不改服务端时,重放永远不生效,而**没有任何东西会红**。
|
||
const sseMgr = code(join(REPO_ROOT, 'server/internal/sse/manager.go'));
|
||
|
||
/**
|
||
* WebUI 实时性:SSE 订阅必须**跟着账号凭证**走 + 断线重连要能补回漏掉的事件。
|
||
*
|
||
* ## 缺陷 1(用户报:页面停留不动,新邮件不自动同步)
|
||
*
|
||
* `App.tsx` 里 `useEffect(..., [phase])` 只在**登录态**变化时连一次 SSE,
|
||
* 而切号(`accountStore.setActive`)只换 `api/config` 的 base/token、**不改 phase**
|
||
* ⇒ 连接仍绑旧账号:旧账号的新邮件照收,新账号的一封都不推。
|
||
* 而 `fetchInbox` 走新凭证 ⇒ 数据是新的。
|
||
* ⇒ 表现正是「不自动同步,但手动刷新能看到」——因为刷新走的是新凭证。
|
||
*
|
||
* 修法:effect 依赖加上「当前凭证身份」。不在切号处显式重建订阅,
|
||
* 因为那要改所有调用点、漏一处就不刷新;凭证变化的唯一发生地是 api/config。
|
||
*
|
||
* ## 缺陷 2:断线重连不重放
|
||
*
|
||
* 服务端一直支持按 `Last-Event-ID` 回放(`sse/manager.go` 的 ring.replay,500 条缓冲),
|
||
* 而 EventSource 断线后**本来会自己重连并带上该头**。
|
||
* 但本文件的 onerror 主动 `close(false)` 再 `open()` —— **换了 EventSource 对象**,
|
||
* 而 Last-Event-ID 是浏览器为**那个对象**记的,新对象什么都不知道 ⇒ 服务端拿不到 ⇒ 不回放。
|
||
*
|
||
* 又因为 EventSource **不能设请求头**,只能走 query,
|
||
* 所以服务端也要相应支持读 `?lastEventId=`(两侧都要改,缺一半都不生效)。
|
||
*/
|
||
|
||
test('★ SSE 订阅跟着凭证走:App 的 effect 依赖含凭证身份', () => {
|
||
assert.ok(
|
||
/useCredentialChange/.test(app),
|
||
'App.tsx 必须取当前凭证身份(否则切号后 SSE 仍绑旧账号)');
|
||
// 关键:依赖数组里必须有它
|
||
assert.ok(
|
||
/useEffect\(\(\) => \{[\s\S]*?connectSSE\([\s\S]*?\}, \[phase, credentialSig\]\)/.test(app),
|
||
'SSE effect 的依赖数组必须含 credentialSig —— 只有 [phase] 时切号不重连');
|
||
});
|
||
|
||
test('凭证身份 = base + token(不含 user:同账号换令牌不该误触发)', () => {
|
||
const m = sse.match(/export function credentialSignature\(\)[\s\S]*?\n}/);
|
||
assert.ok(m, '缺少 credentialSignature()');
|
||
const body = m[0];
|
||
assert.ok(/API_BASE/.test(body), '身份必须含 base(不同网关是不同的连接)');
|
||
assert.ok(/getToken\(\)/.test(body), '身份必须含 token(不同账号是不同的通道)');
|
||
});
|
||
|
||
test('★ 断线重连带上 lastEventId(否则服务端不回放 = 断线期间的新邮件永远看不到)', () => {
|
||
assert.ok(/lastEventId/.test(sse), '未记录/传递 lastEventId');
|
||
// 从 connected 帧与事件帧里取游标
|
||
assert.ok(
|
||
/addEventListener\('connected',[\s\S]{0,400}lastEventId\s*=/.test(sse),
|
||
'必须从 connected 帧的 data.id 记下游标(那是服务端给的游标)');
|
||
// URL 上要真的带上 —— ★ 必须连**条件**一起钉。
|
||
//
|
||
// 我第一版只查那行字符串存在,变异把 `if (lastEventId)` 改成 `if (false)`
|
||
// 后判据**照样绿**:正则匹配的是文本,而缺陷在控制流(那行永远不执行)。
|
||
// ⇒ 断言必须同时包含「取 lastEventId」+ 「据此追加 query」两段。
|
||
assert.ok(
|
||
/url \+= `\$\{sep\}lastEventId=\$\{encodeURIComponent\(lastEventId\)\}`/.test(sse),
|
||
'EventSource 不能设请求头 ⇒ lastEventId 必须进 query');
|
||
assert.ok(
|
||
/if \(lastEventId\) \{/.test(sse),
|
||
'★ 追加 query 的条件必须是 `if (lastEventId)` —— ' +
|
||
'只查那行文本存在的话,`if (false)` 让它永不执行也照样绿(实测过)');
|
||
// 且条件读的是同一个变量(不是别的)
|
||
assert.ok(
|
||
/open\(resumeFrom = ''\)/.test(sse),
|
||
'open() 必须接受 resumeFrom 并写入 lastEventId(否则重连参数无处落地)');
|
||
});
|
||
|
||
test('★ 切号时不得带上旧账号的 lastEventId', () => {
|
||
// 拿旧 id 去问新账号会把别的事件流搅乱 —— 两种重连必须分开
|
||
assert.ok(
|
||
/credsChanged = connectedWith !== '' && connectedWith !== credentialSignature\(\)/.test(sse),
|
||
'必须检测凭证是否变化');
|
||
assert.ok(
|
||
/const resumeFrom = credsChanged \? '' : lastEventId;/.test(sse),
|
||
'凭证变了就必须**不带** lastEventId(它属于旧账号的 ring)');
|
||
});
|
||
|
||
test('★ 服务端也认 lastEventId query(EventSource 不能设头,两侧要同步)', () => {
|
||
// 只改客户端不改服务端 = 重放永远不生效,而且没有任何东西会红
|
||
assert.ok(
|
||
/r\.URL\.Query\(\)\.Get\("lastEventId"\)/.test(sseMgr),
|
||
'服务端必须读 ?lastEventId= —— 否则客户端带了也没人收');
|
||
// 仍要保留标准头(Agent 侧 curl/SDK 可能走 EventSource 之外的路径)
|
||
assert.ok(
|
||
/r\.Header\.Get\("Last-Event-ID"\)/.test(sseMgr),
|
||
'必须保留标准头兜底(不能为了 query 把原路径删掉)');
|
||
}); |