// @vitest-environment node // sse/parser.test.js —— SSE 解析器单测(多行 data / 注释帧 / 无 event 名 / CRLF / 分块 UTF-8) import { describe, it, expect, vi } from 'vitest'; import { parseFrame, SseParser, consumeSseStream } from '../../../../ui/src/sse/parser.js'; describe('parseFrame', () => { it('解析单行 data 与 event 名', () => { const ev = parseFrame('event: progress\ndata: {"phase":"planning"}\n'); expect(ev.event).toBe('progress'); expect(ev.data).toEqual({ phase: 'planning' }); }); it('多行 data 按 \\n 合并后 JSON 解析', () => { const ev = parseFrame('data: {"a":1,\ndata: "b":2}\n'); expect(ev.data).toEqual({ a: 1, b: 2 }); expect(ev.dataStr).toBe('{"a":1,\n"b":2}'); }); it('注释帧(keepalive)返回 null', () => { expect(parseFrame(': ping\n')).toBeNull(); expect(parseFrame(': 任何注释\n: 再来一行\n')).toBeNull(); }); it('无 event 名时默认 message', () => { const ev = parseFrame('data: {"x":1}\n'); expect(ev.event).toBe('message'); expect(ev.data).toEqual({ x: 1 }); }); it('CRLF 行尾与空行容忍', () => { const ev = parseFrame('event: complete\r\ndata: {"ok":true}\r\n'); expect(ev.event).toBe('complete'); expect(ev.data).toEqual({ ok: true }); }); it('非 JSON data 保留原字符串', () => { const ev = parseFrame('data: 纯文本\n'); expect(ev.data).toBe('纯文本'); expect(ev.dataStr).toBe('纯文本'); }); it('无 data 行返回 null;冒号后仅去一个空格', () => { expect(parseFrame('event: progress\n')).toBeNull(); const ev = parseFrame('data: 保留多余空格\n'); expect(ev.dataStr).toBe(' 保留多余空格'); }); }); describe('SseParser(增量)', () => { it('跨 chunk 截断的完整帧', () => { const got = []; const p = new SseParser({ onEvent: (ev) => got.push(ev) }); p.feed('event: progress\nda'); p.feed('ta: {"phase":"planning"}\n\n'); p.end(); expect(got).toHaveLength(1); expect(got[0].event).toBe('progress'); expect(got[0].data).toEqual({ phase: 'planning' }); }); it('多字节 UTF-8 字符跨 chunk 不丢字', () => { const got = []; const p = new SseParser({ onEvent: (ev) => got.push(ev) }); const text = JSON.stringify({ label: '证据审核完成' }); const bytes = new TextEncoder().encode(`data: ${text}\n\n`); p.feed(bytes.slice(0, 5)); p.feed(bytes.slice(5)); p.end(); expect(got[0].data.label).toBe('证据审核完成'); }); it('注释帧被忽略、CRLF 帧正常', () => { const got = []; const p = new SseParser({ onEvent: (ev) => got.push(ev) }); p.feed(': ping\n\n'); p.feed('event: timeout\r\ndata: {"message":"检索超时"}\r\n\r\n'); p.end(); expect(got).toHaveLength(1); expect(got[0].event).toBe('timeout'); expect(got[0].data.message).toBe('检索超时'); }); it('一 chunk 多帧', () => { const got = []; const p = new SseParser({ onEvent: (ev) => got.push(ev) }); p.feed('data: {"a":1}\n\ndata: {"b":2}\n\n: ping\n\n'); p.end(); expect(got.map((e) => e.data)).toEqual([{ a: 1 }, { b: 2 }]); }); }); describe('consumeSseStream', () => { const chunkStream = (chunks) => { const enc = new TextEncoder(); let i = 0; return { body: { getReader() { return { read: vi.fn(async () => (i < chunks.length ? { done: false, value: enc.encode(chunks[i++]) } : { done: true })), releaseLock: vi.fn(), }; }, }, }; }; it('消费完整流并逐帧回调', async () => { const got = []; const resp = chunkStream([ 'event: progress\ndata: {"phase":"planning"}\n\n', ': ping\n\n', 'event: complete\ndata: {"answer":"好"}\n\n', ]); await consumeSseStream(resp, (ev) => got.push(ev)); expect(got.map((e) => e.event)).toEqual(['progress', 'complete']); expect(got[1].data.answer).toBe('好'); }); it('非流响应抛错', async () => { await expect(consumeSseStream({ body: null }, () => {})).rejects.toThrow(/readable stream/); }); });