- 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>
51 lines
2.3 KiB
JavaScript
51 lines
2.3 KiB
JavaScript
// @vitest-environment jsdom
|
||
// markdown.test.js —— Markdown 渲染 + DOMPurify 消毒 + 自定义表格/代码类(FR-CHAT-004)
|
||
import { describe, it, expect } from 'vitest';
|
||
import { renderMarkdown, NON_MONO_HEADER } from '../../../../frontend/src/components/chat/markdown.js';
|
||
|
||
describe('renderMarkdown(FR-CHAT-004)', () => {
|
||
it('LLM 输出 markdown → 消毒 HTML', () => {
|
||
const html = renderMarkdown('**加粗** 与 *斜体* 与 `行内代码`');
|
||
expect(html).toContain('<strong>加粗</strong>');
|
||
expect(html).toContain('<em>斜体</em>');
|
||
expect(html).toContain('<code>行内代码</code>');
|
||
});
|
||
|
||
it('脚本注入被消毒(活 DOM 中无可执行元素/属性/协议)', () => {
|
||
const html = renderMarkdown('你好 <script>alert(1)</script> <img src=x onerror="alert(2)"> [x](javascript:alert(3))');
|
||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
expect(doc.querySelectorAll('script').length).toBe(0);
|
||
expect(doc.querySelectorAll('img').length).toBe(0);
|
||
expect(doc.querySelectorAll('[onerror]').length).toBe(0);
|
||
const hrefs = [...doc.querySelectorAll('a')].map((a) => a.getAttribute('href') || '');
|
||
expect(hrefs.filter((h) => h.toLowerCase().startsWith('javascript:'))).toEqual([]);
|
||
});
|
||
|
||
it('表格挂 md-table 类(thead th / tbody td)', () => {
|
||
const html = renderMarkdown('| A | B |\n|---|---|\n| 1 | 2 |');
|
||
expect(html).toContain('<table class="md-table">');
|
||
expect(html).toContain('<thead>');
|
||
expect(html).toContain('<th>A</th>');
|
||
expect(html).toContain('<tbody>');
|
||
});
|
||
|
||
it('代码块挂 code-block 类并转义内容', () => {
|
||
const html = renderMarkdown('```\nconst x = "<script>";\n```');
|
||
expect(html).toContain('<div class="code-block">');
|
||
expect(html).toContain('<script>');
|
||
});
|
||
|
||
it('渲染失败时回退转义原文,不抛异常、活 DOM 无事件属性', () => {
|
||
const html = renderMarkdown(null);
|
||
expect(typeof html).toBe('string');
|
||
const evil = renderMarkdown('<b onclick=alert(1)>x</b>');
|
||
const doc = new DOMParser().parseFromString(evil, 'text/html');
|
||
expect(doc.querySelectorAll('[onclick]').length).toBe(0);
|
||
expect(evil).not.toContain('<b onclick');
|
||
});
|
||
|
||
it('NON_MONO_HEADER 常量为「说明」', () => {
|
||
expect(NON_MONO_HEADER).toBe('说明');
|
||
});
|
||
});
|