import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readFileSync as read } from 'node:fs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = join(HERE, '..', 'src'); const sse = readFileSync(join(SRC, 'api', 'sse.ts'), 'utf8'); const app = readFileSync(join(SRC, 'App.tsx'), 'utf8'); // 服务端在另一个仓(../../../../server),读它是为了钉「两侧同步」这条 —— // 只改客户端不改服务端时,重放永远不生效,而**没有任何东西会红**。 const sseMgr = read( join(SRC, '..', '..', '..', 'server', 'internal', 'sse', 'manager.go'), 'utf8'); /** * 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 把原路径删掉)'); });