返回 DeepSeek-Reasonix
mermaid-rendering.test.tsx
根目录 / desktop / frontend / src / __tests__ / mermaid-rendering.test.tsx
1 // Run: tsx src/__tests__/mermaid-rendering.test.tsx
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { JSDOM } from "jsdom";
7 import React from "react";
8 import { act } from "react";
9 import { createRoot } from "react-dom/client";
10 import { renderToStaticMarkup } from "react-dom/server";
11 import ReactMarkdown from "react-markdown";
12 import { splitStableMarkdownSections, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
13 import MermaidDiagram from "../components/MermaidDiagram";
14 import {
15 __setMermaidPanZoomFactoryForTest,
16 __setMermaidRenderAdapterForTest,
17 isOpenableMermaidHref,
18 isSafeMermaidHref,
19 safelyRunPanZoom,
20 safelySyncPanZoom,
21 sanitizeMermaidSvg,
22 } from "../components/MermaidDiagram";
23 import { LocaleProvider } from "../lib/i18n";
24 import { REMOTE_MARKDOWN_IMAGE_PATH } from "../lib/markdownImage";
25
26 const testDir = dirname(fileURLToPath(import.meta.url));
27 const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
28 const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
29 const markdownSource = readFileSync(resolve(testDir, "../components/Markdown.tsx"), "utf8");
30 const messageSource = readFileSync(resolve(testDir, "../components/Message.tsx"), "utf8");
31
32 let passed = 0;
33 let failed = 0;
34
35 function ok(value: unknown, label: string) {
36 if (value) {
37 process.stdout.write(` PASS ${label}\n`);
38 passed += 1;
39 } else {
40 process.stdout.write(` FAIL ${label}\n`);
41 failed += 1;
42 }
43 }
44
45 function eq(actual: unknown, expected: unknown, label: string) {
46 if (actual === expected) ok(true, label);
47 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
48 }
49
50 function flushTimers(): Promise<void> {
51 return new Promise((resolve) => setTimeout(resolve, 0));
52 }
53
54 async function waitFor(label: string, predicate: () => boolean) {
55 for (let i = 0; i < 30; i += 1) {
56 if (predicate()) return;
57 await act(async () => {
58 await flushTimers();
59 });
60 }
61 ok(false, label);
62 }
63
64 function installDom() {
65 const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
66 pretendToBeVisual: true,
67 url: "http://localhost/",
68 });
69
70 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
71 globalThis.window = dom.window as unknown as Window & typeof globalThis;
72 globalThis.document = dom.window.document;
73 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
74 globalThis.Node = dom.window.Node;
75 globalThis.Element = dom.window.Element;
76 globalThis.HTMLElement = dom.window.HTMLElement;
77 globalThis.SVGElement = dom.window.SVGElement;
78 globalThis.Event = dom.window.Event;
79 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
80 globalThis.MouseEvent = dom.window.MouseEvent;
81 globalThis.DOMParser = dom.window.DOMParser;
82 globalThis.XMLSerializer = dom.window.XMLSerializer;
83 globalThis.MutationObserver = dom.window.MutationObserver;
84 globalThis.localStorage = dom.window.localStorage;
85 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
86 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
87 Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
88 configurable: true,
89 value: () => ({ x: 0, y: 0, width: 640, height: 360, top: 0, right: 640, bottom: 360, left: 0, toJSON: () => ({}) }),
90 });
91 Object.defineProperty(dom.window, "matchMedia", {
92 configurable: true,
93 value: (query: string) => ({
94 matches: false,
95 media: query,
96 onchange: null,
97 addEventListener: () => undefined,
98 removeEventListener: () => undefined,
99 addListener: () => undefined,
100 removeListener: () => undefined,
101 dispatchEvent: () => false,
102 }),
103 });
104 globalThis.matchMedia = dom.window.matchMedia;
105
106 const style = document.createElement("style");
107 style.textContent = styles;
108 document.head.appendChild(style);
109
110 return dom;
111 }
112
113 {
114 const dom = installDom();
115 const container = document.createElement("div");
116 const throwing = {
117 destroy: () => {},
118 resize: () => { throw new DOMException("matrix is not invertible", "InvalidStateError"); },
119 fit: () => { throw new DOMException("matrix is not invertible", "InvalidStateError"); },
120 center: () => {},
121 zoomIn: () => { throw new DOMException("matrix is not invertible", "InvalidStateError"); },
122 zoomOut: () => {},
123 reset: () => {},
124 };
125 eq(safelySyncPanZoom(throwing, container), false, "matrix failures are contained during pan/zoom layout sync");
126 eq(safelyRunPanZoom(throwing, () => throwing.zoomIn()), false, "matrix failures are contained during toolbar actions");
127 Object.defineProperty(container, "getBoundingClientRect", {
128 value: () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) }),
129 });
130 eq(safelySyncPanZoom(throwing, container), false, "zero-sized layouts are deferred before SVG matrix work");
131 dom.window.close();
132 }
133
134 function parseSvg(svg: string): Document {
135 return new DOMParser().parseFromString(svg, "image/svg+xml");
136 }
137
138 console.log("\nmermaid rendering");
139
140 {
141 ok(markdownSource.includes("requestAnimationFrame"), "streaming markdown commits on an animation frame");
142 ok(markdownSource.includes("streamingMarkdownCommitInterval"), "streaming markdown applies an adaptive parse budget");
143 ok(markdownSource.includes('className="md md--stream-tail"'), "streaming markdown exposes an immediate lightweight tail");
144 ok(markdownSource.includes("requestIdleCallback"), "large Markdown finalization waits for browser idle time");
145 ok(markdownSource.includes("reasonix:markdown-finalize"), "large Markdown finalization emits a performance measure");
146 ok(markdownSource.includes("splitStableMarkdownSections"), "large Markdown retains completed top-level sections");
147 ok(markdownRendererSource.includes("bare = false"), "stable Markdown sections share one semantic container");
148 ok(
149 styles.includes(".md > :where(") && styles.includes("contain-intrinsic-size: auto 72px"),
150 "Markdown blocks skip offscreen layout while preserving learned intrinsic sizes",
151 );
152 ok(markdownSource.includes("streaming?: boolean"), "Markdown exposes an explicit streaming state");
153 ok(messageSource.includes("streaming={item.streaming}"), "assistant messages pass streaming state to Markdown");
154 ok(
155 markdownRendererSource.includes('lazy(() => import("./MermaidDiagram"))'),
156 "MarkdownRenderer lazy-loads the Mermaid renderer",
157 );
158 ok(
159 markdownRendererSource.includes('lang === "mermaid"'),
160 "MarkdownRenderer routes mermaid fenced code blocks to the Mermaid renderer",
161 );
162 }
163
164 {
165 const section = (index: number) => `# Section ${index}\n\n${`paragraph-${index} `.repeat(700)}\n\n`;
166 const document = Array.from({ length: 8 }, (_, index) => section(index)).join("");
167 const chunks = splitStableMarkdownSections(document);
168 ok(chunks.length >= 4, "large headed Markdown is divided into bounded stable chunks");
169 eq(chunks.join(""), document, "stable Markdown chunking preserves every source byte");
170
171 const appended = splitStableMarkdownSections(document + section(8));
172 eq(appended.slice(0, chunks.length).join(""), document, "appending a section leaves all completed chunks unchanged");
173
174 const fenced = `${section(0)}\`\`\`text\n# not a heading\n${"fenced content\n".repeat(900)}\`\`\`\n\n${section(1)}`;
175 const fencedChunks = splitStableMarkdownSections(fenced);
176 eq(fencedChunks.join(""), fenced, "fenced Markdown chunking preserves source bytes");
177 ok(!fencedChunks.some((chunk) => chunk.startsWith("# not a heading")), "headings inside fenced code never become section boundaries");
178
179 const referenced = `${document}\n[shared]: https://example.com\n\nUse [shared].\n`;
180 eq(splitStableMarkdownSections(referenced).length, 1, "cross-section references use one semantic Markdown renderer");
181
182 const nestedFence = `1. item\n\n ${"long paragraph ".repeat(1_000)}\n\n \`\`\`text\n code\n \`\`\`\n\n2. next\n`;
183 const nestedChunks = splitStableMarkdownSections(nestedFence);
184 eq(nestedChunks.length, 1, "list containers stay in one semantic Markdown renderer");
185 const renderMarkdown = (source: string) => renderToStaticMarkup(<ReactMarkdown>{source}</ReactMarkdown>);
186 eq(
187 nestedChunks.map(renderMarkdown).join(""),
188 renderMarkdown(nestedFence),
189 "stable Markdown optimization preserves nested list and fence DOM semantics",
190 );
191 }
192
193 {
194 eq(streamingMarkdownCommitInterval(1_000), 50, "short streaming Markdown uses the 50ms parse budget");
195 eq(streamingMarkdownCommitInterval(8_000), 150, "medium streaming Markdown uses the 150ms parse budget");
196 eq(streamingMarkdownCommitInterval(32_000), 300, "long streaming Markdown uses the 300ms parse budget");
197 }
198
199 {
200 const dom = installDom();
201 const rootEl = document.getElementById("root");
202 if (!rootEl) throw new Error("missing root");
203 let nextFrameID = 1;
204 let pendingFrame: FrameRequestCallback | undefined;
205 globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
206 pendingFrame = callback;
207 return nextFrameID++;
208 }) as typeof requestAnimationFrame;
209 globalThis.cancelAnimationFrame = (() => {
210 pendingFrame = undefined;
211 }) as typeof cancelAnimationFrame;
212
213 const root = createRoot(rootEl);
214 function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
215 return <div>{useRenderedMarkdownText(text, streaming)}</div>;
216 }
217 await act(async () => {
218 root.render(<MarkdownTextProbe text="start" streaming />);
219 await flushTimers();
220 });
221 eq(rootEl.textContent, "start", "streaming Markdown starts from the current text");
222
223 await act(async () => {
224 root.render(<MarkdownTextProbe text="start middle" streaming />);
225 await flushTimers();
226 root.render(<MarkdownTextProbe text="start middle end" streaming />);
227 await new Promise((resolve) => setTimeout(resolve, 60));
228 });
229 eq(rootEl.textContent, "start", "streaming Markdown holds intermediate text until the animation frame");
230 const frame = pendingFrame;
231 await act(async () => {
232 frame?.(performance.now());
233 await flushTimers();
234 });
235 eq(rootEl.textContent, "start middle end", "one animation frame commits the latest streamed text");
236
237 pendingFrame = undefined;
238 await act(async () => {
239 root.render(<MarkdownTextProbe text="start middle end later" streaming />);
240 await flushTimers();
241 });
242 eq(pendingFrame, undefined, "streaming Markdown waits for a fresh budget after the previous DOM commit");
243
244 await act(async () => {
245 root.render(<MarkdownTextProbe text="complete" streaming={false} />);
246 });
247 eq(rootEl.textContent, "complete", "short stream finalization still commits immediately");
248
249 await act(async () => root.unmount());
250 dom.window.close();
251 }
252
253 {
254 const dom = installDom();
255 const rootEl = document.getElementById("root");
256 if (!rootEl) throw new Error("missing root");
257 let pendingIdle: (() => void) | undefined;
258 Object.defineProperty(dom.window, "requestIdleCallback", {
259 configurable: true,
260 value: (callback: () => void) => {
261 pendingIdle = callback;
262 return 1;
263 },
264 });
265 Object.defineProperty(dom.window, "cancelIdleCallback", {
266 configurable: true,
267 value: () => {
268 pendingIdle = undefined;
269 },
270 });
271 Object.defineProperty(dom.window, "setTimeout", {
272 configurable: true,
273 value: (callback: TimerHandler) => {
274 if (typeof callback === "function") callback();
275 return 1;
276 },
277 });
278 Object.defineProperty(dom.window, "clearTimeout", {
279 configurable: true,
280 value: () => undefined,
281 });
282
283 const root = createRoot(rootEl);
284 const streamed = "a".repeat(8_100);
285 const finalText = `${streamed} final`;
286 function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
287 return <div>{useRenderedMarkdownText(text, streaming)}</div>;
288 }
289 await act(async () => {
290 root.render(<MarkdownTextProbe text={streamed} streaming />);
291 await flushTimers();
292 });
293 await act(async () => {
294 root.render(<MarkdownTextProbe text={finalText} streaming={false} />);
295 await flushTimers();
296 });
297 eq(rootEl.textContent, streamed, "large Markdown keeps its committed content while finalization waits for idle");
298 ok(Boolean(pendingIdle), "large Markdown schedules one idle finalization callback");
299
300 await act(async () => {
301 pendingIdle?.();
302 await flushTimers();
303 });
304 eq(rootEl.textContent, finalText, "idle finalization commits the complete large Markdown text");
305
306 await act(async () => root.unmount());
307 dom.window.close();
308 }
309
310 {
311 const dom = installDom();
312 Object.defineProperty(dom.window, "runtime", { configurable: true, value: {} });
313 const dirtySvg = `
314 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onload="steal()">
315 <script>alert(1)</script>
316 <a id="safe" href="https://example.com/diagram"><text>safe</text></a>
317 <a id="unsafe" href="javascript:alert(1)"><text>bad</text></a>
318 <a id="unsafe-xlink" xlink:href="data:text/html,boom"><text>bad</text></a>
319 <image id="remote-image" href="https://images.example.com/diagram.png" />
320 <use id="external-use" href="https://images.example.com/icons.svg#node" />
321 <g onclick="steal()"><text>node</text></g>
322 </svg>`;
323 const sanitized = sanitizeMermaidSvg(dirtySvg);
324 const doc = parseSvg(sanitized);
325
326 ok(!doc.documentElement.hasAttribute("onload"), "sanitizer strips event attributes from the root SVG");
327 ok(!doc.querySelector("script"), "sanitizer removes script nodes");
328 ok(doc.querySelector("#safe")?.getAttribute("href") === "https://example.com/diagram", "sanitizer keeps safe external links");
329 ok(!doc.querySelector("#unsafe")?.hasAttribute("href"), "sanitizer removes javascript links");
330 ok(!doc.querySelector("#unsafe-xlink")?.hasAttribute("xlink:href"), "sanitizer removes data xlink links");
331 ok(
332 doc.querySelector("#remote-image")?.getAttribute("href")?.startsWith(`${REMOTE_MARKDOWN_IMAGE_PATH}?url=`) === true,
333 "sanitizer routes Mermaid image resources through the backend proxy",
334 );
335 ok(!doc.querySelector("#external-use")?.hasAttribute("href"), "sanitizer removes external SVG use resources");
336 ok(!doc.querySelector("g")?.hasAttribute("onclick"), "sanitizer strips event attributes from child nodes");
337 ok(isSafeMermaidHref("https://example.com/a"), "https Mermaid links are safe");
338 ok(isSafeMermaidHref("mailto:hello@example.com"), "mailto Mermaid links are safe");
339 ok(!isSafeMermaidHref("file:///tmp/private"), "file Mermaid links are not safe");
340 ok(!isOpenableMermaidHref("#internal"), "fragment Mermaid links are not opened externally");
341
342 dom.window.close();
343 }
344
345 {
346 const dom = installDom();
347 const openedUrls: string[] = [];
348 dom.window.open = ((url: string | URL | undefined) => {
349 if (url) openedUrls.push(String(url));
350 return null;
351 }) as Window["open"];
352
353 const renders: Array<{ definition: string; theme: string }> = [];
354 const panZoomCalls: string[] = [];
355
356 __setMermaidRenderAdapterForTest(async (_svgId, definition, theme, signal) => {
357 if (signal.aborted) throw new DOMException("Aborted", "AbortError");
358 renders.push({ definition, theme });
359 return `
360 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 80" onload="steal()">
361 <script>alert(1)</script>
362 <a id="safe-link" href="https://example.com/diagram"><text>Open</text></a>
363 <a id="unsafe-link" href="vbscript:msgbox(1)"><text>Blocked</text></a>
364 <g class="node"><text>Rendered Mermaid</text></g>
365 </svg>`;
366 });
367
368 __setMermaidPanZoomFactoryForTest(() => ({
369 destroy: () => { panZoomCalls.push("destroy"); },
370 resize: () => { panZoomCalls.push("resize"); },
371 fit: () => { panZoomCalls.push("fit"); },
372 center: () => { panZoomCalls.push("center"); },
373 zoomIn: () => { panZoomCalls.push("zoomIn"); },
374 zoomOut: () => { panZoomCalls.push("zoomOut"); },
375 reset: () => { panZoomCalls.push("reset"); },
376 }));
377
378 const rootEl = document.getElementById("root");
379 if (!rootEl) throw new Error("missing root");
380 const root = createRoot(rootEl);
381
382 await act(async () => {
383 root.render(
384 <LocaleProvider>
385 <div className="chat-pane">
386 <MermaidDiagram definition={"graph TD\nA-->B"} />
387 </div>
388 </LocaleProvider>,
389 );
390 await flushTimers();
391 });
392
393 await waitFor("Mermaid preview SVG rendered in DOM", () => Boolean(document.querySelector(".mermaid-diagram__preview svg")));
394 ok(document.querySelector(".mermaid-diagram__toolbar"), "Mermaid renderer shows its toolbar");
395 eq(renders.length, 1, "Mermaid renderer calls the render adapter once");
396 eq(renders[0]?.definition, "graph TD\nA-->B", "Mermaid renderer passes the diagram definition to Mermaid");
397 ok(document.querySelector("#safe-link"), "safe SVG link remains in the rendered DOM");
398 ok(!document.querySelector("#unsafe-link")?.hasAttribute("href"), "unsafe SVG link href is stripped in the rendered DOM");
399 ok(!document.querySelector(".mermaid-diagram__preview svg")?.hasAttribute("onload"), "rendered SVG root event handler is stripped");
400 ok(!document.querySelector(".mermaid-diagram__preview script"), "rendered SVG script nodes are removed");
401
402 await waitFor("pan zoom instance initialized", () => panZoomCalls.includes("fit") && panZoomCalls.includes("center"));
403
404 const zoomIn = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]');
405 const zoomOut = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]');
406 const reset = document.querySelector<HTMLButtonElement>('button[aria-label="Reset zoom"]');
407 zoomIn?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
408 zoomOut?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
409 reset?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
410 ok(panZoomCalls.includes("zoomIn"), "zoom in button calls the pan zoom instance");
411 ok(panZoomCalls.includes("zoomOut"), "zoom out button calls the pan zoom instance");
412 ok(panZoomCalls.includes("reset"), "reset zoom button calls the pan zoom instance");
413
414 document.querySelector("#safe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
415 eq(openedUrls[0], "https://example.com/diagram", "SVG links open through the external browser bridge");
416 document.querySelector("#unsafe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
417 eq(openedUrls.length, 1, "unsafe SVG links do not open externally");
418
419 await act(async () => {
420 document.querySelector<HTMLButtonElement>('button[aria-label="Show diagram source"]')
421 ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
422 await flushTimers();
423 });
424 ok(document.querySelector(".mermaid-diagram__code")?.textContent?.includes("graph TD"), "source tab shows the Mermaid definition");
425
426 await act(async () => {
427 document.querySelector<HTMLButtonElement>('button[aria-label="Open fullscreen"]')
428 ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
429 await flushTimers();
430 });
431 ok(document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "fullscreen diagram portals into the chat pane");
432
433 await act(async () => {
434 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
435 await flushTimers();
436 });
437 ok(!document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "Escape closes the Mermaid fullscreen portal");
438
439 await act(async () => {
440 root.unmount();
441 });
442 __setMermaidRenderAdapterForTest(null);
443 __setMermaidPanZoomFactoryForTest(undefined);
444 dom.window.close();
445 }
446
447 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
448 if (failed > 0) process.exit(1);
449
449 lines Plain Text