Files
PlantAI-rag-new/tests/frontend/unit/b2/parser.test.js
T
jerryandClaude Fable 5 1e94de7464 PlantAI-rag-new 首次提交:文献知识工作台
- 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>
2026-08-25 20:47:52 +08:00

124 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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/);
});
});