This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
// tests/unit/renderer/reader/markdown-reader.test.tsx
//
// 守 MarkdownReader 的行为契约
// (src/renderer/src/components/reader/MarkdownReader.tsx 头注指向本文件,
// 原先标 🔒(守卫缺失);其中「安全前置」一条是源码注释**点名**要守的)。
//
// 组件做什么:markdown-it 渲染 → 字符串层净化 → dangerouslySetInnerHTML。
// 用户可见的结果分三块:
// 1. 渲染 —— 标题 / 列表 / 粗体 / 代码块 / 表格都变成真的 HTML 元素;
// 文档第一行的 # 标题额外给 SR 一个隐藏 h1;容器带 filename 的 aria-label。
// 2. 【安全前置】—— markdown-it 实例必须带 `html: false`:
// 正文里的原始 HTML **不被解析**(只作为文本显示),
// 否则 <script> 会经 dangerouslySetInnerHTML 直接进 DOM;
// 这一层之上还有字符串净化(markdown-html-sanitize):
// 危险 scheme(javascript: / data: / vbscript:)的链接一律被中和。
// 退化后果:用户笔记里的 `<script>` 或 `javascript:` 链接在 renderer
// 进程里可执行 ⇒ 经 window.api 间接调用所有 IPC ⇒ 用户数据 RCE。
// (实测:markdown-it 自己的 validateLink 已经先挡掉 markdown 链接里的
// javascript:,本测锁的是**端到端用户可见**的结果 —— 渲染结果里
// 永远没有危险 scheme 的可点链接,且原文不被静默吞掉。)
// 3. 空态 / 失败态 —— 空正文给明确空态提示(而不是空 <p>);
// markdown-it chunk 加载失败给错误态(而不是永久白屏)。
//
// 变异思路(哪几处改动会红):
// - markdown-it 选项 html: false → true ⇒ 「正文里的 <script> 是文本」红;
// - 去掉 sanitizeMarkdownHtml 调用(或放宽 scheme 白名单)⇒ 危险链接红;
// - linkify: true → false ⇒ 裸 URL 自动成链红;
// - 去掉 fence/code_block 的尾换行裁剪 ⇒ 代码块文本多一个 \n 红;
// - 空内容分支改成渲染空 article ⇒ 空态红;
// - 动态 import 的 catch 分支去掉 ⇒ 加载失败变白屏红。
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { MarkdownReader } from '../../../../src/renderer/src/components/reader/MarkdownReader';
/**
* markdown-it 是**动态 import** —— 等它真正渲染完。
* 注意:article 元素在 markdown-it 到位**之前**就已挂载(正文区是空的),
* 且组件还会额外渲染一个给 SR 用的隐藏 h1 —— 所以「article 里有字」并不代表
* markdown 渲染完了。真正的判据是 markdown 输出容器(article > div)非空。
*/
async function waitForRendered(container: HTMLElement) {
await waitFor(
() => {
const md = container.querySelector('article > div');
expect(md).not.toBeNull();
expect(md!.innerHTML.trim().length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
}
afterEach(() => {
cleanup();
});
describe('MarkdownReader:markdown 渲染', () => {
it('标题 / 列表 / 粗体 / 行内代码都变成真的 HTML 元素', async () => {
const { container } = render(
<MarkdownReader
source={'# 一级标题\n\n- 甲\n- 乙\n\n这是**粗体**与 `code`\n'}
filename="a.md"
/>,
);
await waitForRendered(container);
expect(container.querySelector('article h1')!.textContent).toBe('一级标题');
expect(container.querySelectorAll('article li')).toHaveLength(2);
expect(container.querySelector('article strong')!.textContent).toBe('粗体');
expect(container.querySelector('article code')!.textContent).toBe('code');
});
it('代码块内容末尾没有多余换行', async () => {
const { container } = render(
<MarkdownReader source={'```js\nconst a = 1;\n```\n'} filename="a.md" />,
);
await waitForRendered(container);
const code = container.querySelector('article pre code')!;
expect(code.textContent).toBe('const a = 1;');
expect(code.textContent!.endsWith('\n')).toBe(false);
});
it('文档第一行的 # 标题额外给 SR 一个隐藏 h1;容器带 filename 的 aria-label', async () => {
const { container } = render(
<MarkdownReader source={'# 主题\n\n正文\n'} filename="研究笔记.md" />,
);
await waitForRendered(container);
const srHeading = container.querySelector('article h1.sr-only');
expect(srHeading).not.toBeNull();
expect(srHeading!.textContent).toBe('主题');
// 容器对外暴露可访问名
expect(container.querySelector('[aria-label="研究笔记.md"]')).not.toBeNull();
});
it('[[wiki 链接]] 渲染为纯文本,不是可点锚点', async () => {
const { container } = render(
<MarkdownReader source={'参见 [[另一篇笔记]] 的说明'} filename="a.md" />,
);
await waitForRendered(container);
expect(container.querySelector('article')!.textContent).toContain('[[另一篇笔记]]');
expect(container.querySelector('article a')).toBeNull();
});
});
describe('MarkdownReader:安全前置(html:false + scheme 白名单)', () => {
it('正文里的原始 HTML 只作为文本显示,不进 DOM', async () => {
const { container } = render(
<MarkdownReader
source={'<script>alert(1)</script>\n\n<img src=x onerror="alert(2)">\n'}
filename="a.md"
/>,
);
await waitForRendered(container);
// DOM 里不得出现脚本 / 图片元素
expect(container.querySelector('script')).toBeNull();
expect(container.querySelector('img')).toBeNull();
// 字面量以文本形式完整保留(用户能看见自己写的东西)
expect(container.textContent).toContain('<script>alert(1)</script>');
expect(container.textContent).toContain('onerror');
});
it('危险 scheme 不会变成可点链接,原文仍可见', async () => {
const { container } = render(
<MarkdownReader
source={
'[点我](javascript:alert(1))\n\n访问 https://example.com\n\n裸的 javascript:alert(2) 也在正文里\n'
}
filename="a.md"
/>,
);
await waitForRendered(container);
// 渲染结果里只有**安全**的那个链接(markdown-it 的 validateLink 先挡一道,
// 字符串净化再兜一道 —— 两道都在 ⇒ 用户笔记里的危险链接永远点不动)
const hrefs = Array.from(container.querySelectorAll('article a')).map((a) =>
(a.getAttribute('href') ?? '').toLowerCase(),
);
expect(hrefs).toEqual(['https://example.com']);
for (const href of hrefs) {
expect(href).not.toMatch(/^(javascript|vbscript|data|file):/);
}
// 且原文没有被静默吞掉(用户能看到自己写了什么)
expect(container.textContent).toContain('javascript:alert(1)');
expect(container.textContent).toContain('javascript:alert(2)');
});
it('裸 URL 被自动链接化,http(s) 链接保持原样', async () => {
const { container } = render(
<MarkdownReader source={'访问 https://example.com 看看'} filename="a.md" />,
);
await waitForRendered(container);
const anchor = container.querySelector('article a')!;
expect(anchor.getAttribute('href')).toBe('https://example.com');
});
});
describe('MarkdownReader:空态与失败态', () => {
it('空白正文 → 明确空态提示,而不是一个空 article', async () => {
const { container } = render(<MarkdownReader source={' \n\n'} filename="空.md" />);
await waitFor(() => expect(screen.getByText('reader.emptyDoc')).toBeTruthy());
expect(container.querySelector('article')).toBeNull();
});
it('markdown-it 初始化失败 → 错误态提示,不是永久白屏', async () => {
// 真实故障形态:chunk 到了但实例化炸了(asar hash 不符 / 沙盒环境)。
// 工厂返回「构造即抛」的类,错误信息能原样传到 UI 上。
vi.doMock('markdown-it', () => ({
default: class {
constructor() {
throw new Error('chunk load failed');
}
},
}));
const { container } = render(<MarkdownReader source={'# 标题'} filename="a.md" />);
await waitFor(() =>
expect(screen.getByText('reader.errorMarkdownLoaderReaderTitle')).toBeTruthy(),
);
expect(container.querySelector('article')).toBeNull();
// 错误原因对用户可见(可截图报障),不是空壳
expect(container.textContent).toContain('chunk load failed');
vi.doUnmock('markdown-it');
});
});

View File

@@ -0,0 +1,204 @@
// tests/unit/renderer/reader/reader-budget.test.tsx
//
// 守 perf r259「大文件阅读不许把渲染进程主线程堵死」这条契约 ——
// 阅读器的重管线(语法高亮 / markdown 渲染 + HTML 净化)是**同步**的,
// 跑在 renderer 主线程上。实测(2026-10-21):
//
// 代码文件 12.3MB → hljs.highlight 3191ms + 净化 592ms(净化后 HTML 57.8MB)
// markdown 5.6MB → md.render 889ms + 净化 1943ms
//
// main 侧阅读器上限 READER_MAX_SIZE_BYTES = 10MB ⇒ 这些量级用户真能碰到
// ⇒ 窗口「未响应」数秒。修法 = **降级**而不是截断:超预算的部分不再走
// 重管线,但**内容一字不丢**(以纯文本呈现)。
//
// ── 本测锁五件事 ───────────────────────────────────────────────────
// A. splitByBudget 的三条不变量:
// ① head + tail === 原文(**内容零丢失**);
// ② 未超预算 ⇒ tail 为空、truncated 为 false;
// ③ 超预算 ⇒ 切点落在**行边界**(不把一行劈成两半)。
// B. CodeReader:超大文件 ⇒ 只有预算内的一段被高亮(高亮 span 数有界),
// 但**尾部内容仍在 DOM 里**(用户看得见完整文件),且出现降级提示条。
// C. MarkdownReader:超大文件 ⇒ 预算外的部分以 <pre> 纯文本呈现
// (**不被 markdown 排版**),内容完整,并出现降级提示条。
// D. 小文件**不受影响**:照常高亮 / 照常排版,且**没有**提示条。
// E. 提示条上的「在系统应用打开」出口存在(想要完整高亮的用户有去处)。
//
// 变异思路(哪几处改动会红):
// - splitByBudget 改成「截断丢弃尾巴」⇒ A① / B / C 红(内容丢);
// - 预算调回 Infinity(不做降级)⇒ B / C 红(高亮 span 数无界 / 无提示条);
// - 尾巴忘了接上(head+tail 少一段)⇒ B / C 红;
// - 提示条不渲染 ⇒ B / C 红。
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { CodeReader } from '../../../../src/renderer/src/components/reader/CodeReader';
import { MarkdownReader } from '../../../../src/renderer/src/components/reader/MarkdownReader';
import {
READER_HIGHLIGHT_BUDGET_CHARS,
READER_MARKDOWN_BUDGET_CHARS,
splitByBudget,
} from '../../../../src/renderer/src/lib/reader-budget';
afterEach(() => {
cleanup();
});
/** 造一份「行数足够多、尾部有唯一标记」的源码。 */
function makeSource(totalChars: number, marker: string): string {
const line = 'def f(x):\n return x + 1 # comment\n';
let s = '';
while (s.length < totalChars) s += line;
s += marker;
return s;
}
describe('splitByBudget:内容零丢失的不变量', () => {
it('A1 未超预算 ⇒ head 就是全文,tail 为空,truncated 为 false', () => {
const text = 'abc\ndef\n';
const r = splitByBudget(text, 1024);
expect(r.truncated).toBe(false);
expect(r.tail).toBe('');
expect(r.head).toBe(text);
expect(r.headChars).toBe(text.length);
});
it('A2 超预算 ⇒ head + tail 拼回原文(一个字符都不丢)', () => {
const text = makeSource(5000, 'TAIL-MARKER\n');
const r = splitByBudget(text, 1000);
expect(r.truncated).toBe(true);
expect(r.head.length).toBeLessThanOrEqual(1000);
expect(r.head + r.tail).toBe(text);
expect(r.tail).toContain('TAIL-MARKER');
});
it('A3 超预算 ⇒ 切点落在行边界(不把一行劈成两半)', () => {
const text = makeSource(5000, 'TAIL-MARKER\n');
const r = splitByBudget(text, 1000);
expect(r.head.endsWith('\n')).toBe(true);
});
it('A4 整段没有换行的超长单行 ⇒ 硬切在预算处,内容仍零丢失', () => {
const text = 'x'.repeat(5000);
const r = splitByBudget(text, 1000);
expect(r.truncated).toBe(true);
expect(r.head.length).toBe(1000);
expect(r.head + r.tail).toBe(text);
});
});
describe('CodeReader:大文件降级(内容不丢,高亮有界)', () => {
it('B1 超大文件 ⇒ 只有预算内的一段被高亮,尾部内容仍在 DOM 里', async () => {
// 标记用「一定会被 hljs 包成 span」的形态(def = python 关键字):
// 若整份文件都被高亮,这个标记的文本节点就会挂在 span 下面;
// 降级后它是 <code> 的**直接文本子节点** ⇒ 断言文本节点的父元素。
const marker = 'def zzz_tail_marker():\n return 1\n';
const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, marker);
const { container } = render(<CodeReader source={source} filename="big.py" />);
// 等 hljs chunk 到位(动态 import)—— 高亮 span 出现即算到位。
await waitFor(() => {
expect(container.querySelectorAll('code span').length).toBeGreaterThan(0);
});
// ① 内容零丢失:尾部标记在 DOM 里(纯文本呈现)
expect(container.textContent).toContain('zzz_tail_marker');
// ② 尾部**没有被高亮**:标记所在的文本节点直接挂在 <code> 下
const code = container.querySelector('code')!;
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
let parentTag = '';
while (node) {
if (node.textContent?.includes('zzz_tail_marker')) {
parentTag = (node.parentElement?.tagName ?? '').toUpperCase();
break;
}
node = walker.nextNode();
}
expect(
parentTag,
'尾部被高亮了 ⇒ 渲染预算没生效(整份文件仍在跑 hljs,主线程会卡数秒)',
).toBe('CODE');
});
it('B2 出现降级提示条(用户有权知道「后面那段没高亮」)', async () => {
const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, 'TAIL\n');
render(<CodeReader source={source} filename="big.py" />);
await waitFor(() => expect(screen.getByRole('status')).toBeTruthy());
expect(screen.getByRole('status').textContent).toContain('reader.largeCodeNotice');
});
it('B3 提示条带「在系统应用打开」出口(想要完整高亮有去处)', async () => {
const source = makeSource(READER_HIGHLIGHT_BUDGET_CHARS + 20000, 'TAIL\n');
const onOpenExternal = vi.fn();
render(<CodeReader source={source} filename="big.py" onOpenExternal={onOpenExternal} />);
const btn = await screen.findByRole('button', { name: 'reader.openExternal' });
btn.click();
expect(onOpenExternal).toHaveBeenCalledTimes(1);
});
it('D1 小文件 ⇒ 照常高亮,且**没有**降级提示条', async () => {
const source = 'def f(x):\n return x + 1\n';
const { container } = render(<CodeReader source={source} filename="small.py" />);
await waitFor(() => {
expect(container.querySelectorAll('code span').length).toBeGreaterThan(0);
});
expect(screen.queryByRole('status')).toBeNull();
});
});
describe('MarkdownReader:大文件降级(排版有界,内容不丢)', () => {
it('C1 超大文件 ⇒ 预算外的部分以纯文本呈现,不被 markdown 排版', async () => {
const marker = 'TAIL-MARKER-MD **not-bold**';
const head = '# Title\n\nsome **bold** text\n\n';
let source = head;
while (source.length < READER_MARKDOWN_BUDGET_CHARS + 20000) source += 'line of text\n\n';
source += marker + '\n';
const { container } = render(<MarkdownReader source={source} filename="big.md" />);
await waitFor(() => {
expect(container.querySelector('article > div')!.innerHTML.trim().length).toBeGreaterThan(0);
});
// ① 内容零丢失:尾部标记在 DOM 里(以 <pre> 纯文本呈现)
expect(container.textContent).toContain('TAIL-MARKER-MD');
// ② 预算外的部分**没有**进 markdown 渲染产物 —— 这是本测的关键断言:
// markdown 输出容器里不得出现尾部标记(否则说明整份文件仍在走
// md.render,主线程照样被堵数秒)。
const mdOut = container.querySelector('article > div')!;
expect(
mdOut.textContent,
'尾部被 markdown 渲染了 ⇒ 渲染预算没生效(整份文件仍在跑 md.render)',
).not.toContain('TAIL-MARKER-MD');
// ③ 预算内的部分照常排版(只有一个 strong,来自正文)
expect(mdOut.querySelectorAll('strong')).toHaveLength(1);
expect(container.querySelector('article strong')!.textContent).toBe('bold');
expect(container.textContent).toContain('**not-bold**');
});
it('C2 出现降级提示条', async () => {
let source = '# Title\n\n';
while (source.length < READER_MARKDOWN_BUDGET_CHARS + 20000) source += 'line of text\n\n';
render(<MarkdownReader source={source} filename="big.md" />);
await waitFor(() => expect(screen.getByRole('status')).toBeTruthy());
expect(screen.getByRole('status').textContent).toContain('reader.largeMarkdownNotice');
});
it('D2 小文件 ⇒ 照常排版,且没有降级提示条', async () => {
const { container } = render(
<MarkdownReader source={'# 标题\n\n正文**粗体**\n'} filename="small.md" />,
);
await waitFor(() => {
expect(container.querySelector('article strong')!.textContent).toBe('粗体');
});
expect(screen.queryByRole('status')).toBeNull();
});
});