2026-08-25 20:47:08 +08:00
|
|
|
|
// @vitest-environment node
|
|
|
|
|
|
// sse/parser.test.js —— SSE 解析器单测(多行 data / 注释帧 / 无 event 名 / CRLF / 分块 UTF-8)
|
|
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
2026-08-31 12:56:47 +08:00
|
|
|
|
import { parseFrame, SseParser, consumeSseStream } from '../../../../ui/src/sse/parser.js';
|
2026-08-25 20:47:08 +08:00
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|