本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

200 lines
7.2 KiB

/**
* markdown.ts Markdown 渲染器单元测试
*
* 重点覆盖:
* 1. 基础语法正确性(标题、粗体、斜体、列表、链接、引用、代码)
* 2. XSS 防护(先转义再转换、链接白名单 http/https)
*/
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../src/markdown';
describe('renderMarkdown - 基础语法', () => {
it('空字符串返回空', () => {
expect(renderMarkdown('')).toBe('');
});
it('null/undefined 返回空', () => {
expect(renderMarkdown(null as never)).toBe('');
expect(renderMarkdown(undefined as never)).toBe('');
});
it('普通文本渲染为段落', () => {
expect(renderMarkdown('你好世界')).toContain('<p class="csk-md-p">你好世界</p>');
});
it('标题渲染 h1-h6', () => {
expect(renderMarkdown('# 标题一')).toContain('<h1 class="csk-md-h1">');
expect(renderMarkdown('## 标题二')).toContain('<h2 class="csk-md-h2">');
expect(renderMarkdown('### 标题三')).toContain('<h3 class="csk-md-h3">');
});
it('粗体渲染', () => {
expect(renderMarkdown('**粗体文本**')).toContain('<strong>粗体文本</strong>');
});
it('斜体渲染', () => {
expect(renderMarkdown('*斜体文本*')).toContain('<em>斜体文本</em>');
});
it('删除线渲染', () => {
expect(renderMarkdown('~~删除文本~~')).toContain('<del>删除文本</del>');
});
it('无序列表渲染', () => {
const result = renderMarkdown('- 项目一\n- 项目二');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<li>');
expect(result).toContain('项目一');
expect(result).toContain('项目二');
});
it('有序列表渲染', () => {
const result = renderMarkdown('1. 第一步\n2. 第二步');
expect(result).toContain('<ol class="csk-md-ol">');
expect(result).toContain('<li>');
});
it('引用渲染', () => {
const result = renderMarkdown('> 这是引用');
expect(result).toContain('<blockquote class="csk-md-blockquote">');
expect(result).toContain('这是引用');
});
it('链接渲染(http 协议)', () => {
const result = renderMarkdown('[百度](https://www.baidu.com)');
expect(result).toContain('href="https://www.baidu.com"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('水平线渲染', () => {
expect(renderMarkdown('---')).toContain('<hr class="csk-md-hr">');
});
});
describe('renderMarkdown - 代码', () => {
it('行内代码渲染', () => {
const result = renderMarkdown('使用 `console.log` 调试');
expect(result).toContain('<code class="csk-md-inline-code">console.log</code>');
});
it('代码块渲染', () => {
const result = renderMarkdown('```js\nconsole.log("hello");\n```');
expect(result).toContain('<pre class="csk-md-code-block">');
expect(result).toContain('class="language-js"');
expect(result).toContain('console.log');
});
it('代码块内容不被 Markdown 处理', () => {
const result = renderMarkdown('```\n**粗体** *斜体*\n```');
// 代码块内的 Markdown 语法不应被转换
expect(result).toContain('**粗体**');
expect(result).toContain('*斜体*');
expect(result).not.toContain('<strong>');
});
});
describe('renderMarkdown - XSS 防护', () => {
it('转义 HTML 标签', () => {
const result = renderMarkdown('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('转义 img 标签的 onerror', () => {
const result = renderMarkdown('<img src=x onerror=alert(1)>');
// < 被转义为 &lt;,标签不会执行,onerror 变为纯文本
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('链接只允许 http/https 协议', () => {
const result = renderMarkdown('[恶意](javascript:alert(1))');
expect(result).toContain('href="#"');
expect(result).not.toContain('javascript:');
});
it('允许 http 链接', () => {
const result = renderMarkdown('[安全](http://example.com)');
expect(result).toContain('href="http://example.com"');
});
it('代码块内容同样被转义', () => {
const result = renderMarkdown('```\n<script>alert(1)</script>\n```');
expect(result).not.toContain('<script>alert');
expect(result).toContain('&lt;script&gt;');
});
it('行内代码内容同样被转义', () => {
const result = renderMarkdown('`<img src=x>`');
expect(result).toContain('&lt;img');
expect(result).not.toContain('<img');
});
});
describe('renderMarkdown - 复合内容', () => {
it('混合标题和段落', () => {
const result = renderMarkdown('# 标题\n\n这是正文');
expect(result).toContain('<h1');
expect(result).toContain('<p class="csk-md-p">这是正文</p>');
});
it('多段落正确分隔', () => {
const result = renderMarkdown('第一段\n\n第二段');
expect(result).toContain('第一段');
expect(result).toContain('第二段');
// 应有两个 <p> 标签
const pCount = (result.match(/<p class="csk-md-p">/g) || []).length;
expect(pCount).toBe(2);
});
});
describe('renderMarkdown - GFM tables and tolerant headings', () => {
it('accepts a no-space heading before CJK text', () => {
expect(renderMarkdown('##\u4e00\u3001\u5e72\u51bb\u8d27/\u751f\u9c9c\u6a21\u5757')).toContain(
'<h2 class="csk-md-h2">\u4e00\u3001\u5e72\u51bb\u8d27/\u751f\u9c9c\u6a21\u5757</h2>',
);
});
it('accepts a no-space heading before a digit', () => {
expect(renderMarkdown('##1 purchase warehouse')).toContain(
'<h2 class="csk-md-h2">1 purchase warehouse</h2>',
);
});
it('accepts up to three leading spaces for block syntax', () => {
const result = renderMarkdown(
' ## 1\uFE0F\u20E3 purchase warehouse\n\n - **field**: warehouseid',
);
expect(result).toContain('<h2 class="csk-md-h2">1\uFE0F\u20E3 purchase warehouse</h2>');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<strong>field</strong>');
});
it('does not treat an English hashtag as a heading', () => {
expect(renderMarkdown('#topic')).toContain('<p class="csk-md-p">#topic</p>');
});
it('renders a GFM table with alignment and inline formatting', () => {
const result = renderMarkdown(
'|\u5b57\u6bb5|\u8bf4\u660e|\u6821\u9a8c\u89c4\u5219|\n|---|:---:|---:|\n|**warehouseId**|ID|required|',
);
expect(result).toContain('<table class="csk-md-table">');
expect(result).toContain('<th>\u5b57\u6bb5</th>');
expect(result).toContain('<th class="csk-md-align-center">\u8bf4\u660e</th>');
expect(result).toContain('<td class="csk-md-align-right">required</td>');
expect(result).toContain('<strong>warehouseId</strong>');
});
it('keeps table cell HTML escaped', () => {
const result = renderMarkdown('|field|note|\n|---|---|\n|name|<img src=x onerror=alert(1)>|');
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('does not treat ordinary pipe text as a table', () => {
const result = renderMarkdown('A | B\nnot a separator');
expect(result).not.toContain('<table');
});
});