/** * 共用 SSE 帧解析器的行为约定。 * * 三个平台桥共用同一份(deploy/check-shared-libs.sh 校验逐字节相同)。 * 这里钉住的是**曾经真实丢帧**的两个场景,以及凭据在重连时的正确用法。 * * 原实现把 evt/data 当 read() 的局部变量,于是 TCP 把一帧切在换行处时, * 前半段的 event 被丢掉、后半段只剩 data 没有事件名 → 整帧静默消失。 * 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。 */ import { test } from "node:test"; import assert from "node:assert/strict"; import { createFrameParser } from "../lib/sse-client.js"; /** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */ function parse(s) { try { return JSON.parse(s); } catch (e) { assert.fail(`不是合法 JSON: ${s}(${e.message})`); } } test("完整帧一次喂入:正常解析", () => { const p = createFrameParser(); const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n'); assert.equal(events.length, 1); assert.equal(events[0].event, "new_mail"); assert.deepEqual(parse(events[0].data), { mail_id: "m1" }); assert.equal(events[0].id, "7"); assert.equal(p.lastEventId(), "7"); }); test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => { const p = createFrameParser(); // chunk1 恰好停在 event 行之后、data 行之前 const first = p.push("id: 12\nevent: content_delta\n"); assert.deepEqual(first, [], "半帧不该派发"); const second = p.push('data: {"x":1}\n\n'); assert.equal( second.length, 1, "跨 chunk 的半帧必须被拼回完整事件,而不是丢弃", ); assert.equal(second[0].event, "content_delta"); assert.equal(p.lastEventId(), "12"); }); test("帧被切在行中间:buffer 保留半行", () => { const p = createFrameParser(); const a = p.push("event: new_ma"); assert.deepEqual(a, []); const b = p.push('il\ndata: {"mail_id":"m9"}\n\n'); assert.equal(b.length, 1); assert.equal(b[0].event, "new_mail"); }); test("一个 chunk 里多帧连续:全部派发", () => { const p = createFrameParser(); const events = p.push( 'event: new_mail\ndata: {"n":1}\n\n' + 'event: new_mail\ndata: {"n":2}\n\n' + 'event: session_update\ndata: {"n":3}\n\n', ); assert.equal(events.length, 3); assert.deepEqual( events.map((e) => e.event), ["new_mail", "new_mail", "session_update"], ); }); test("注释/心跳行被忽略,不影响后续帧", () => { const p = createFrameParser(); const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n'); assert.equal(events.length, 1); assert.equal(events[0].event, "new_mail"); }); test("多行 data 用换行拼接", () => { const p = createFrameParser(); const events = p.push("event: x\ndata: line1\ndata: line2\n\n"); assert.equal(events[0].data, "line1\nline2"); }); test("CRLF 不被当成事件名或 JSON 的一部分", () => { const p = createFrameParser(); const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n'); assert.equal(events.length, 1); assert.equal(events[0].event, "new_mail"); assert.equal(events[0].id, "3"); assert.deepEqual(parse(events[0].data), { n: 1 }); }); test("事件 id 只向前推进:重放旧 id 不回退断点", () => { const p = createFrameParser(); p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n'); assert.equal(p.lastEventId(), "10"); // 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10 p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n'); assert.equal( p.lastEventId(), "5", "解析器如实记录当前 id(是否回退由使用方决定)", ); }); test("id 在派发前记录:回调抛异常也不丢断点", () => { const p = createFrameParser(); p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n'); assert.equal(p.lastEventId(), "42"); }); test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => { const p = createFrameParser(); const events = p.push('data: {"orphan":true}\n\n'); assert.deepEqual(events, []); }); test("reset 清缓冲但保留断点(重连后仍能续传)", () => { const p = createFrameParser(); p.push('id: 99\nevent: a\ndata: {"n":1}\n\n'); p.push("event: partial"); // 半帧 p.reset(); assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放"); // reset 后半帧不该复活 const after = p.push('data: {"n":2}\n\n'); assert.deepEqual(after, []); }); test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => { const p = createFrameParser(); p.push('id: 123\nevent: a\ndata: {"n":1}\n\n'); assert.equal(p.lastEventId(), "123"); // connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉 p.setLastEventId(""); assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID"); });