- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化 - Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab - 四档响应式(桌面/笔记本/平板/手机)、深浅主题 - 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测 - 真实凭据已脱敏,运行时配置在 backend/.env(不入 git) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
4.1 KiB
JavaScript
124 lines
4.1 KiB
JavaScript
// @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 '../../../../frontend/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/);
|
||
});
|
||
});
|