Files
MailUI4Agents/client/electron/test/sse-credentials.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

103 lines
5.6 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 } 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 把原路径删掉)');
});