返回 DeepSeek-Reasonix
markdown-pipeline.test.tsx
根目录 / desktop / frontend / src / __tests__ / markdown-pipeline.test.tsx
1 // Run: tsx src/__tests__/markdown-pipeline.test.tsx
2 //
3 // Parse-parity goldens for the isomorphic markdown pipeline (Phase E): the
4 // worker/fallback pipeline must render byte-identical static markup to the
5 // production react-markdown path for the same document, both unsliced and
6 // sliced into blocks (footnotes/reference definitions resolve across blocks
7 // because parsing is whole-document).
8
9 import { createElement, Fragment, type ReactNode } from "react";
10 import { renderToStaticMarkup } from "react-dom/server";
11 import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
12 import { JSDOM } from "jsdom";
13 import { normalizeMath } from "../components/mathNormalize";
14 import { createComponents } from "../components/markdownComponents";
15 import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
16 import { hastBlockToJsx } from "../lib/hastJsx";
17 import { visibleMarkdownBlockCount } from "../lib/markdownDomBudget";
18 import {
19 defaultMarkdownUrlTransform,
20 estimateHastBytes,
21 markdownContentRevision,
22 markdownUrlTransform,
23 parseMarkdown,
24 parseMarkdownToBlocks,
25 parseMarkdownToHast,
26 sliceHastBlocks,
27 type MarkdownBlock,
28 } from "../lib/markdownPipeline";
29
30 let passed = 0;
31 let failed = 0;
32
33 function ok(value: unknown, label: string) {
34 if (value) {
35 process.stdout.write(` PASS ${label}\n`);
36 passed += 1;
37 } else {
38 process.stdout.write(` FAIL ${label}\n`);
39 failed += 1;
40 }
41 }
42
43 function eq(actual: unknown, expected: unknown, label: string) {
44 if (actual === expected) ok(true, label);
45 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
46 }
47
48 function renderCurrent(text: string): string {
49 return renderToStaticMarkup(
50 createElement(ReactMarkdown, {
51 remarkPlugins: reasonixRemarkPlugins,
52 rehypePlugins: reasonixRehypePlugins,
53 components: createComponents(false),
54 urlTransform: markdownUrlTransform,
55 children: normalizeMath(text),
56 }),
57 );
58 }
59
60 function renderBlocks(blocks: MarkdownBlock[]): string {
61 const components = createComponents(false);
62 return renderToStaticMarkup(
63 createElement(Fragment, {
64 children: blocks.map((block) =>
65 createElement(Fragment, { key: block.key, children: hastBlockToJsx(block, components) as ReactNode })),
66 }),
67 );
68 }
69
70 function projectRenderedBlocks(blocks: MarkdownBlock[]): string {
71 const dom = new JSDOM(`<!doctype html><body><div id="root" data-transcript-selectable>${renderBlocks(blocks)}</div></body>`);
72 globalThis.Node = dom.window.Node;
73 globalThis.Element = dom.window.Element;
74 globalThis.HTMLElement = dom.window.HTMLElement;
75 const root = dom.window.document.getElementById("root") as HTMLElement;
76 const selection = dom.window.getSelection()!;
77 selection.selectAllChildren(root);
78 const projected = selection.toString();
79 dom.window.close();
80 return projected;
81 }
82
83 console.log("\nmarkdown pipeline parity");
84
85 const bigCode = "```js\n" + "const value = compute(index); // keep this line long enough\n".repeat(4000) + "```";
86 ok(bigCode.length > 100_000, "code-fence fixture exceeds 100KB");
87
88 const fixtures: Record<string, string> = {
89 headingsAndProse: "# Title\n\nHello **world**, this is [a link](https://example.com).\n\nSecond paragraph with `code`.",
90 gfmTable: "| name | value |\n| --- | ---: |\n| a | 1 |\n| b | 2 |",
91 taskList: "- [x] done\n- [ ] todo\n- [ ] item with **bold** and `code`",
92 strikethroughAutolink: "~~gone~~ and https://example.com/auto plus www.example.com",
93 mathInline: "Price is $5 and math $x^2$ works. Also \\(y=1\\) and $E=mc^2$.",
94 mathBlockRepair: "Before\n\n\\[\n\\int_0^1 x\\,dx\n\\]\n\nAfter\n\n$$\n\\frac{a}{b}\n$$",
95 mathInlinePipe: "| formula | note |\n| --- | --- |\n| $|x|$ cell | pipe inside math |",
96 footnotes: "Text with a note[^1] and another[^long].\n\n[^1]: first note\n\n[^long]: second note with [ref link][r]\n\n[r]: https://example.com/ref",
97 crossBlockRefs: "Use [shared] here.\n\n## Section\n\nMore text.\n\n## Later\n\nAgain [shared] and [other].\n\n[shared]: https://example.com/shared\n[other]: https://example.com/other",
98 mermaidFence: "```mermaid\ngraph TD\nA-->B\n```",
99 bigCodeFence: bigCode,
100 cjk: "中文段落,包含「引号」和路径 D:\\work\\项目\\文件.md。\n\n- 列表项一\n- 列表项二",
101 rawHtml: "Before <div class=\"x\">raw</div> after\n\n<script>alert(1)</script>",
102 unsafeAndFileLinks: "[bad](javascript:alert(1)) and [file](file:///tmp/a%20b.txt) and D:\\src\\app.ts",
103 manyBlocks: Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\nParagraph ${i} with *emphasis*.\n`).join("\n"),
104 };
105
106 for (const [name, text] of Object.entries(fixtures)) {
107 const expected = renderCurrent(text);
108 const root = parseMarkdownToHast(text);
109 const whole = renderBlocks([{ key: "whole", children: root.children }]);
110 eq(whole, expected, `${name}: pipeline render matches react-markdown`);
111 const blocks = sliceHastBlocks(root);
112 const sliced = renderBlocks(blocks);
113 eq(sliced, expected, `${name}: sliced blocks render identically (${blocks.length} blocks)`);
114 }
115
116 // Empty fenced blocks are formatting placeholders. They must not create a
117 // bordered CodeViewer, while comment-only and whitespace-adjacent blocks stay
118 // visible as real code content.
119 {
120 const emptyFences = "Before\n\n```\n\n```\n\nAfter\n\n```ts\n \n```";
121 const html = renderCurrent(emptyFences);
122 ok(!html.includes("code-block"), "empty fenced blocks do not render phantom code cards");
123 ok(html.includes("Before") && html.includes("After"), "text around empty fenced blocks remains visible");
124
125 const comments = "```ts\n// keep this comment\nconst stable = true;\n```";
126 const commentHtml = renderCurrent(comments);
127 ok(commentHtml.includes("keep this comment"), "non-empty comment code blocks remain visible");
128 ok(commentHtml.includes("code-block"), "non-empty comment code keeps its code-block surface");
129 }
130
131 // Block keys are stable top-level indexes.
132 {
133 const blocks = parseMarkdownToBlocks("one\n\ntwo\n\nthree");
134 eq(blocks.map((b) => b.key).join(","), "b0,b1,b2", "block keys are stable indexes");
135 ok(blocks.every((block) => (block.elementCount ?? 0) > 0), "parse stamps DOM element counts with block fingerprints");
136 }
137
138 // Progressive DOM publication keeps semantic blocks whole and advances by an
139 // explicit element budget instead of mounting an unbounded parsed document.
140 {
141 const blocks = parseMarkdownToBlocks(Array.from({ length: 12 }, (_, index) =>
142 `## Part ${index}\n\nParagraph with **bold** and [link](https://example.com/${index}).`).join("\n\n"));
143 const first = visibleMarkdownBlockCount(blocks, 8);
144 ok(first > 0 && first < blocks.length, "small DOM budget publishes a strict leading block page");
145 ok(visibleMarkdownBlockCount(blocks, 10_000) === blocks.length, "larger DOM budget makes every block reachable");
146 }
147
148 // Footnote definitions survive slicing as a trailing block with working refs.
149 {
150 const text = "first[^a]\n\n## middle\n\nsecond[^b]\n\n[^a]: note a\n[^b]: note b";
151 const blocks = parseMarkdownToBlocks(text);
152 const last = blocks[blocks.length - 1];
153 const lastHtml = renderBlocks([last]);
154 ok(lastHtml.includes("data-footnotes"), "footnote section is the trailing block");
155 const sliced = renderBlocks(blocks);
156 ok(sliced.includes('href="#user-content-fn-a"'), "footnote reference links survive slicing");
157 ok(sliced.includes('id="user-content-fn-a"'), "footnote definition anchors survive slicing");
158 }
159
160 // The copied defaultUrlTransform must match react-markdown's across protocols.
161 {
162 const corpus = [
163 "https://example.com/a?b=c#d",
164 "http://example.com",
165 "mailto:user@example.com",
166 "javascript:alert(1)",
167 "vbscript:x",
168 "data:text/html,boom",
169 "./relative/path",
170 "../up",
171 "/absolute",
172 "#fragment",
173 "query?only",
174 "ftp://example.com/file",
175 "file:///etc/passwd",
176 "HTTPS://EXAMPLE.COM/upper",
177 "ircs://irc.example.com/chan",
178 "xmpp:user@example.com",
179 "a:b:c",
180 "",
181 "C:\\src\\app.ts",
182 ];
183 for (const url of corpus) {
184 eq(defaultMarkdownUrlTransform(url), defaultUrlTransform(url), `urlTransform parity for ${JSON.stringify(url)}`);
185 }
186 }
187
188 // Authority-form UNC links use the same strict local-file allowlist in the
189 // worker pipeline as canonical file:/// links do.
190 {
191 const unc = "file://nas/share/report.md";
192 eq(markdownUrlTransform(unc), unc, "authority-form UNC survives pipeline URL sanitization");
193 const root = parseMarkdownToHast(`[report](${unc})`);
194 const html = renderBlocks([{ key: "unc", children: root.children }]);
195 ok(html.includes(`href="${unc}"`), "authority-form UNC href survives HAST rendering");
196 }
197
198 // Windows device namespaces and alternate data streams must not be restored
199 // after react-markdown's default URL sanitizer rejects their file: scheme.
200 for (const unsafe of [
201 "file://./PhysicalDrive0",
202 "file:////?/C:/Windows",
203 "file:///C:/safe.txt:payload",
204 "file:///tmp/report.md?download=1",
205 "file:///tmp/report.md#section",
206 ]) {
207 eq(markdownUrlTransform(unsafe), "", `unsafe local URL is blanked: ${unsafe}`);
208 }
209
210 // Content revision + byte weight.
211 {
212 eq(markdownContentRevision("alpha") === markdownContentRevision("alpha"), true, "content revision is deterministic");
213 eq(markdownContentRevision("alpha") !== markdownContentRevision("beta"), true, "content revision distinguishes texts");
214 const blocks = parseMarkdownToBlocks("hello **world**");
215 const bytes = estimateHastBytes(blocks);
216 ok(bytes > 0 && bytes < 100_000, "hast byte estimate is positive and bounded");
217 const bigBlocks = parseMarkdownToBlocks(bigCode);
218 ok(estimateHastBytes(bigBlocks) > bytes, "hast byte estimate grows with content");
219 }
220
221 console.log("\nmarkdown selection projection");
222
223 {
224 const result = parseMarkdown([
225 "# 标题 😀",
226 "",
227 "段落 [链接文字](https://example.com) 与 $x^2$。",
228 "",
229 "内联 **粗体** *斜体*。",
230 "",
231 "- 第一项",
232 "- 第二项",
233 "",
234 "```ts",
235 "const value = 1;",
236 "```",
237 "",
238 "| 名称 | 值 |",
239 "| --- | --- |",
240 "| 一 | 1 |",
241 ].join("\n"));
242 eq(
243 result.selectionText,
244 "标题 😀\n段落 链接文字 与 $x^2$。\n内联 粗体 斜体。\n第一项\n第二项\nconst value = 1;\n名称\t值\n一\t1",
245 "selection projection preserves readable structure, code, tables, CJK, emoji and LaTeX",
246 );
247 const selected = projectRenderedBlocks(result.blocks);
248 ok(selected.includes("标题 😀") && selected.includes("链接文字") && selected.includes("const value = 1;"),
249 "native DOM selection includes Unicode, links and complete code text");
250 eq(result.selectionRevision, markdownContentRevision(result.selectionText), "selection revision fingerprints projected UTF-16 text");
251 }
252
253 {
254 const rows = Array.from({ length: 52 }, (_, index) => `| row-${index} | ${index} |`).join("\n");
255 const result = parseMarkdown(`| name | value |\n| --- | --- |\n${rows}`);
256 ok(result.blocks.some((block) => block.virtualTable), "large plain table uses the virtual table representation");
257 ok(result.selectionText.includes("row-51\t51"), "virtual table projection includes rows that never mount in the DOM");
258 }
259
260 // ── block fingerprints ──────────────────────────────────────────────────────
261 // The render path keeps a previous AST object when key and fingerprint match,
262 // so the fingerprint must be equal exactly when the block's rendered content is
263 // equal. A false "unchanged" leaves a stale block on screen.
264 {
265 const same = parseMarkdown("paragraph one\n\n- a\n- b");
266 const again = parseMarkdown("paragraph one\n\n- a\n- b");
267 eq(
268 again.blocks.map(block => block.fingerprint).join(","),
269 same.blocks.map(block => block.fingerprint).join(","),
270 "identical sources fingerprint identically",
271 );
272 ok(same.blocks.every(block => Number.isInteger(block.fingerprint)), "every block is stamped with a fingerprint");
273
274 // A block that changed must not be mistaken for its previous self.
275 const appended = parseMarkdown("paragraph one\n\n- a\n- b\n- c");
276 ok(appended.blocks[0].fingerprint === same.blocks[0].fingerprint, "an unchanged leading block keeps its fingerprint");
277 ok(appended.blocks[1].fingerprint !== same.blocks[1].fingerprint, "a grown list block changes its fingerprint");
278
279 // Appending a reference definition rewrites an EARLIER paragraph's link. A
280 // positional-only identity would keep the stale block here.
281 const unresolved = parseMarkdown("see [docs][ref]\n\nand more");
282 const resolved = parseMarkdown("see [docs][ref]\n\nand more\n\n[ref]: https://example.com/doc");
283 ok(unresolved.blocks[0].fingerprint !== resolved.blocks[0].fingerprint,
284 "a reference definition that resolves an earlier link changes that block's fingerprint");
285
286 // Structure the markdown source does not spell out still separates blocks.
287 const inline = parseMarkdown("a **bold** word");
288 const plain = parseMarkdown("a bold word");
289 ok(inline.blocks[0].fingerprint !== plain.blocks[0].fingerprint, "inline emphasis changes the fingerprint");
290
291 // Distinct content must not collide, including across block boundaries.
292 const boundary = parseMarkdown("ab\n\nc");
293 const shifted = parseMarkdown("a\n\nbc");
294 ok(boundary.blocks[0].fingerprint !== shifted.blocks[0].fingerprint, "a shorter first block fingerprints differently");
295 }
296
297 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
298 if (failed > 0) process.exit(1);
299
299 lines Plain Text