返回 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, splitStreamingTailFence, streamingCommitTarget, 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 { createMermaidPanZoom } from "../components/mermaidPanZoom";
24 import { LocaleProvider } from "../lib/i18n";
25 import { REMOTE_MARKDOWN_IMAGE_PATH } from "../lib/markdownImage";
26 import { installDesktopHostStub } from "./desktopHostStub";
27
28 const testDir = dirname(fileURLToPath(import.meta.url));
29 const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
30 const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
31 const markdownComponentsSource = readFileSync(resolve(testDir, "../components/markdownComponents.tsx"), "utf8");
32 const markdownSource = readFileSync(resolve(testDir, "../components/Markdown.tsx"), "utf8");
33 const mermaidDiagramSource = readFileSync(resolve(testDir, "../components/MermaidDiagram.tsx"), "utf8");
34 const messageSource = readFileSync(resolve(testDir, "../components/Message.tsx"), "utf8");
35
36 let passed = 0;
37 let failed = 0;
38
39 function ok(value: unknown, label: string) {
40 if (value) {
41 process.stdout.write(` PASS ${label}\n`);
42 passed += 1;
43 } else {
44 process.stdout.write(` FAIL ${label}\n`);
45 failed += 1;
46 }
47 }
48
49 function eq(actual: unknown, expected: unknown, label: string) {
50 if (actual === expected) ok(true, label);
51 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
52 }
53
54 function flushTimers(): Promise<void> {
55 return new Promise((resolve) => setTimeout(resolve, 0));
56 }
57
58 async function waitFor(label: string, predicate: () => boolean) {
59 for (let i = 0; i < 30; i += 1) {
60 if (predicate()) return;
61 await act(async () => {
62 await flushTimers();
63 });
64 }
65 ok(false, label);
66 }
67
68 function installDom() {
69 const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
70 pretendToBeVisual: true,
71 url: "http://localhost/",
72 });
73
74 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
75 globalThis.window = dom.window as unknown as Window & typeof globalThis;
76 globalThis.document = dom.window.document;
77 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
78 globalThis.Node = dom.window.Node;
79 globalThis.Element = dom.window.Element;
80 globalThis.HTMLElement = dom.window.HTMLElement;
81 globalThis.SVGElement = dom.window.SVGElement;
82 globalThis.Event = dom.window.Event;
83 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
84 globalThis.MouseEvent = dom.window.MouseEvent;
85 globalThis.DOMParser = dom.window.DOMParser;
86 globalThis.XMLSerializer = dom.window.XMLSerializer;
87 globalThis.MutationObserver = dom.window.MutationObserver;
88 globalThis.localStorage = dom.window.localStorage;
89 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
90 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
91 Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
92 configurable: true,
93 value: () => ({ x: 0, y: 0, width: 640, height: 360, top: 0, right: 640, bottom: 360, left: 0, toJSON: () => ({}) }),
94 });
95 Object.defineProperty(dom.window, "matchMedia", {
96 configurable: true,
97 value: (query: string) => ({
98 matches: false,
99 media: query,
100 onchange: null,
101 addEventListener: () => undefined,
102 removeEventListener: () => undefined,
103 addListener: () => undefined,
104 removeListener: () => undefined,
105 dispatchEvent: () => false,
106 }),
107 });
108 globalThis.matchMedia = dom.window.matchMedia;
109
110 const style = document.createElement("style");
111 style.textContent = styles;
112 document.head.appendChild(style);
113
114 return dom;
115 }
116
117 {
118 const dom = installDom();
119 const container = document.createElement("div");
120 const throwing = {
121 destroy: () => {},
122 resize: () => { throw new DOMException("matrix is not invertible", "InvalidStateError"); },
123 fit: () => { throw new DOMException("matrix is not invertible", "InvalidStateError"); },
124 center: () => {},
125 zoomIn: () => { throw new DOMException("matrix is not invertible", "InvalidStateError"); },
126 zoomOut: () => {},
127 reset: () => {},
128 };
129 eq(safelySyncPanZoom(throwing, container), false, "matrix failures are contained during pan/zoom layout sync");
130 eq(safelyRunPanZoom(throwing, () => throwing.zoomIn()), false, "matrix failures are contained during toolbar actions");
131 Object.defineProperty(container, "getBoundingClientRect", {
132 value: () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) }),
133 });
134 eq(safelySyncPanZoom(throwing, container), false, "zero-sized layouts are deferred before SVG matrix work");
135 dom.window.close();
136 }
137
138 // Inline pan/zoom (replaces svg-pan-zoom, #8068): transform math on one
139 // viewport <g>, zoom clamped to 0.3–8, wheel/drag/dblclick interactions.
140 {
141 const dom = installDom();
142 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
143 svg.setAttribute("viewBox", "0 0 160 80");
144 const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
145 const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
146 marker.setAttribute("id", "arrow");
147 defs.appendChild(marker);
148 svg.appendChild(defs);
149 const content = document.createElementNS("http://www.w3.org/2000/svg", "g");
150 content.setAttribute("id", "drawing");
151 svg.appendChild(content);
152 // 640x360 layout box: the meet mapping fits at scale 4, origin (0, 20).
153 Object.defineProperty(svg, "getBoundingClientRect", {
154 configurable: true,
155 value: () => ({ x: 0, y: 0, width: 640, height: 360, top: 0, right: 640, bottom: 360, left: 0, toJSON: () => ({}) }),
156 });
157 document.body.appendChild(svg);
158
159 const panZoom = createMermaidPanZoom(svg, { minZoom: 0.3, maxZoom: 8, zoomScaleSensitivity: 0.3 });
160 const viewport = () => svg.querySelector("g[data-mermaid-pan-zoom-viewport]");
161 ok(svg.querySelector("g[data-mermaid-pan-zoom-viewport] g#drawing"), "inline pan/zoom wraps the drawing in one viewport group");
162 ok(defs.parentNode === svg, "SVG definitions stay outside the transformed viewport");
163 ok(!viewport()?.querySelector("defs"), "the viewport does not transform SVG definitions");
164 ok(svg.querySelector("defs marker#arrow") === marker, "definition children remain available by ID");
165
166 panZoom.fit();
167 eq(viewport()?.getAttribute("transform"), "translate(0 0) scale(1)", "fit keeps the browser meet mapping at unit scale");
168
169 svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
170 eq(viewport()?.getAttribute("transform"), "translate(-24 -12) scale(1.3)", "wheel zooms in around the cursor");
171
172 for (let i = 0; i < 40; i += 1) {
173 svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: 120, bubbles: true, cancelable: true }));
174 }
175 ok(viewport()?.getAttribute("transform")?.endsWith("scale(0.3)"), "wheel zoom-out clamps at the minimum zoom");
176
177 panZoom.reset();
178 eq(viewport()?.getAttribute("transform"), "translate(0 0) scale(1)", "reset restores the fitted unit transform");
179 for (let i = 0; i < 40; i += 1) {
180 svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
181 }
182 ok(viewport()?.getAttribute("transform")?.endsWith("scale(8)"), "wheel zoom-in clamps at the maximum zoom");
183
184 panZoom.reset();
185 svg.dispatchEvent(new dom.window.MouseEvent("pointerdown", { button: 0, clientX: 100, clientY: 100, bubbles: true }));
186 svg.dispatchEvent(new dom.window.MouseEvent("pointermove", { clientX: 130, clientY: 110, bubbles: true }));
187 eq(viewport()?.getAttribute("transform"), "translate(7.5 2.5) scale(1)", "pointer drag pans the drawing in screen pixels");
188 svg.dispatchEvent(new dom.window.MouseEvent("pointerup", { clientX: 130, clientY: 110, bubbles: true }));
189
190 panZoom.destroy();
191 svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
192 eq(viewport()?.getAttribute("transform"), "translate(7.5 2.5) scale(1)", "destroy detaches the interaction listeners");
193
194 ok(!mermaidDiagramSource.includes("svg-pan-zoom"), "MermaidDiagram no longer imports svg-pan-zoom");
195 ok(!styles.includes("svg-pan-zoom"), "styles no longer carry svg-pan-zoom hooks");
196 dom.window.close();
197 }
198
199 function parseSvg(svg: string): Document {
200 return new DOMParser().parseFromString(svg, "image/svg+xml");
201 }
202
203 console.log("\nmermaid rendering");
204
205 {
206 ok(markdownSource.includes("requestAnimationFrame"), "streaming markdown commits on an animation frame");
207 ok(markdownSource.includes("streamingMarkdownCommitInterval"), "streaming markdown applies an adaptive parse budget");
208 ok(markdownSource.includes('text.slice(renderedText.length)'), "streaming markdown exposes an immediate lightweight tail");
209 ok(markdownSource.includes("requestIdleCallback"), "large Markdown finalization waits for browser idle time");
210 ok(markdownSource.includes("reasonix:markdown-finalize"), "large Markdown finalization emits a performance measure");
211 ok(markdownSource.includes("splitStableMarkdownSections"), "large Markdown retains completed top-level sections");
212 ok(markdownRendererSource.includes("bare = false"), "stable Markdown sections share one semantic container");
213 ok(
214 styles.includes(".md > :where(") && styles.includes("contain-intrinsic-size: auto 72px"),
215 "Markdown blocks skip offscreen layout while preserving learned intrinsic sizes",
216 );
217 ok(markdownSource.includes("streaming?: boolean"), "Markdown exposes an explicit streaming state");
218 ok(messageSource.includes("streaming={item.streaming}"), "assistant messages pass streaming state to Markdown");
219 ok(
220 markdownComponentsSource.includes('lazy(() => import("./MermaidDiagram"))'),
221 "the shared components map lazy-loads the Mermaid renderer",
222 );
223 ok(
224 markdownComponentsSource.includes('lang === "mermaid"'),
225 "the shared components map routes mermaid fenced code blocks to the Mermaid renderer",
226 );
227 }
228
229 {
230 const section = (index: number) => `# Section ${index}\n\n${`paragraph-${index} `.repeat(700)}\n\n`;
231 const document = Array.from({ length: 8 }, (_, index) => section(index)).join("");
232 const chunks = splitStableMarkdownSections(document);
233 ok(chunks.length >= 4, "large headed Markdown is divided into bounded stable chunks");
234 eq(chunks.join(""), document, "stable Markdown chunking preserves every source byte");
235
236 const appended = splitStableMarkdownSections(document + section(8));
237 eq(appended.slice(0, chunks.length).join(""), document, "appending a section leaves all completed chunks unchanged");
238
239 const fenced = `${section(0)}\`\`\`text\n# not a heading\n${"fenced content\n".repeat(900)}\`\`\`\n\n${section(1)}`;
240 const fencedChunks = splitStableMarkdownSections(fenced);
241 eq(fencedChunks.join(""), fenced, "fenced Markdown chunking preserves source bytes");
242 ok(!fencedChunks.some((chunk) => chunk.startsWith("# not a heading")), "headings inside fenced code never become section boundaries");
243
244 const referenced = `${document}\n[shared]: https://example.com\n\nUse [shared].\n`;
245 eq(splitStableMarkdownSections(referenced).length, 1, "cross-section references use one semantic Markdown renderer");
246
247 const nestedFence = `1. item\n\n ${"long paragraph ".repeat(1_000)}\n\n \`\`\`text\n code\n \`\`\`\n\n2. next\n`;
248 const nestedChunks = splitStableMarkdownSections(nestedFence);
249 eq(nestedChunks.length, 1, "list containers stay in one semantic Markdown renderer");
250 const renderMarkdown = (source: string) => renderToStaticMarkup(<ReactMarkdown>{source}</ReactMarkdown>);
251 eq(
252 nestedChunks.map(renderMarkdown).join(""),
253 renderMarkdown(nestedFence),
254 "stable Markdown optimization preserves nested list and fence DOM semantics",
255 );
256 }
257
258 {
259 eq(streamingMarkdownCommitInterval(1_000), 50, "short streaming Markdown uses the 50ms parse budget");
260 eq(streamingMarkdownCommitInterval(8_000), 150, "medium streaming Markdown uses the 150ms parse budget");
261 eq(streamingMarkdownCommitInterval(32_000), 300, "long streaming Markdown uses the 300ms parse budget");
262
263 eq(streamingCommitTarget("intro\n\npartial paragraph"), "intro\n\n", "commit target stops at the last completed block");
264 eq(streamingCommitTarget("no blank line yet"), "", "no completed block means nothing to parse yet");
265 eq(streamingCommitTarget("done\n\nalso done\n\n"), "done\n\nalso done\n\n", "trailing boundary commits everything");
266 eq(streamingCommitTarget("t\n\n```js\nstreaming code"), "t\n\n", "an open fence stays in the tail for code-styled streaming");
267 eq(streamingCommitTarget("t\n\n$$\n\\int_0^1"), "t\n\n$$\n\\int_0^1", "open display math keeps the whole text parsed");
268 eq(streamingCommitTarget("t\n\n```\nc\n```\nafter"), "t\n\n```\nc\n```\n", "a closed fence promotes immediately without waiting for a blank line");
269 eq(streamingCommitTarget("t\n\n$$\nx\n$$\nafter"), "t\n\n$$\nx\n$$\n", "closed display math promotes immediately");
270 eq(streamingCommitTarget("para\n## Next sec"), "para\n", "a partial heading line completes the paragraph before it");
271 eq(streamingCommitTarget("para\n## Done\ntail"), "para\n## Done\n", "a terminated heading promotes itself as a complete block");
272
273 const searchItem = (title: string, url: string) => `- **${title}**\n <${url}>`;
274 const searchDump = [
275 searchItem("新闻本文", "https://example.com/a"),
276 searchItem("Bitcoin (BTC) Price", "https://example.com/b?utm_source=x"),
277 searchItem("KuCoin", "https://example.com/c"),
278 ].join("\n");
279 eq(
280 streamingCommitTarget(searchDump),
281 `${searchItem("新闻本文", "https://example.com/a")}\n${searchItem("Bitcoin (BTC) Price", "https://example.com/b?utm_source=x")}\n`,
282 "a later list marker commits prior tight list items, including indented URL continuations",
283 );
284 eq(
285 streamingCommitTarget(`${searchItem("新闻本文", "https://example.com/a")}\n- **Bit`),
286 `${searchItem("新闻本文", "https://example.com/a")}\n`,
287 "an in-progress list marker still commits the previous completed item",
288 );
289 eq(
290 streamingCommitTarget(searchItem("新闻本文", "https://example.com/a")),
291 "",
292 "a single list item stays in the tail until the next item or a blank line",
293 );
294 eq(
295 streamingCommitTarget("1. alpha\n2. beta\n3. gamma"),
296 "1. alpha\n2. beta\n",
297 "ordered list markers complete prior items the same way",
298 );
299 eq(
300 streamingCommitTarget("- [ ] one\n- [x] two\n- [ ] three"),
301 "- [ ] one\n- [x] two\n",
302 "task-list markers complete prior items the same way",
303 );
304 eq(
305 streamingCommitTarget("intro paragraph\n- first item\n continued"),
306 "intro paragraph\n",
307 "a list marker completes the paragraph before it without taking the new item",
308 );
309 eq(
310 streamingCommitTarget("- parent\n - child still typing"),
311 "- parent\n",
312 "a nested list marker completes the parent item and leaves the child in the tail",
313 );
314 eq(
315 streamingCommitTarget("not a heading\n---\nstill setext"),
316 "",
317 "a setext underline is not treated as a list marker",
318 );
319 eq(
320 streamingCommitTarget("*not-a-list*\nstill paragraph"),
321 "",
322 "emphasis without a marker space is not a list item",
323 );
324 eq(
325 streamingCommitTarget("t\n\n```\n- not a list\n- also not\n"),
326 "t\n\n",
327 "list markers inside an open fence do not create commit boundaries",
328 );
329 eq(
330 streamingCommitTarget("- one\n- two\n\n"),
331 "- one\n- two\n\n",
332 "a blank line after a list still commits the whole list",
333 );
334 const searchHtml = renderToStaticMarkup(<ReactMarkdown remarkPlugins={[]}>{searchDump}</ReactMarkdown>);
335 ok(searchHtml.includes("<ul>") && searchHtml.includes("<li>") && searchHtml.includes("新闻本文"),
336 "the search-list source still renders as a list in the final Markdown tree");
337 }
338
339 {
340 const dom = installDom();
341 const rootEl = document.getElementById("root");
342 if (!rootEl) throw new Error("missing root");
343 let nextFrameID = 1;
344 let pendingFrame: FrameRequestCallback | undefined;
345 globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
346 pendingFrame = callback;
347 return nextFrameID++;
348 }) as typeof requestAnimationFrame;
349 globalThis.cancelAnimationFrame = (() => {
350 pendingFrame = undefined;
351 }) as typeof cancelAnimationFrame;
352
353 const root = createRoot(rootEl);
354 function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
355 return <div>{useRenderedMarkdownText(text, streaming)}</div>;
356 }
357 await act(async () => {
358 root.render(<MarkdownTextProbe text="start" streaming />);
359 await flushTimers();
360 });
361 eq(rootEl.textContent, "start", "streaming Markdown starts from the current text");
362
363 await act(async () => {
364 root.render(<MarkdownTextProbe text="start middle" streaming />);
365 await flushTimers();
366 root.render(<MarkdownTextProbe text="start middle end" streaming />);
367 await new Promise((resolve) => setTimeout(resolve, 60));
368 });
369 eq(pendingFrame, undefined, "an in-progress block never schedules a parse commit");
370 eq(rootEl.textContent, "start", "the growing block rides the tail, not the parsed prefix");
371
372 await act(async () => {
373 root.render(<MarkdownTextProbe text={"start middle end\n\nnext block"} streaming />);
374 await new Promise((resolve) => setTimeout(resolve, 60));
375 });
376 const frame = pendingFrame;
377 await act(async () => {
378 frame?.(performance.now());
379 await flushTimers();
380 });
381 eq(rootEl.textContent, "start middle end\n\n", "a completed block commits up to its boundary");
382
383 pendingFrame = undefined;
384 await act(async () => {
385 root.render(<MarkdownTextProbe text={"start middle end\n\nnext block grows"} streaming />);
386 await flushTimers();
387 });
388 eq(pendingFrame, undefined, "tail growth alone schedules no further parse");
389
390 await act(async () => {
391 root.render(<MarkdownTextProbe text={"...\nreplacement window"} streaming />);
392 await flushTimers();
393 });
394 eq(rootEl.textContent, "", "a rolling Markdown window drops its stale parsed prefix before paint");
395
396 await act(async () => {
397 root.render(<MarkdownTextProbe text="complete" streaming={false} />);
398 });
399 eq(rootEl.textContent, "complete", "short stream finalization still commits immediately");
400
401 pendingFrame = undefined;
402 const firstSearch = "- **新闻本文**\n <https://example.com/a>\n";
403 const secondSearch = `${firstSearch}- **Bitcoin**\n <https://example.com/b>`;
404 await act(async () => {
405 root.render(<MarkdownTextProbe text={firstSearch} streaming />);
406 await flushTimers();
407 });
408 eq(rootEl.textContent, "", "a single tight list item stays off the parsed prefix");
409 await act(async () => {
410 root.render(<MarkdownTextProbe text={secondSearch} streaming />);
411 await new Promise((resolve) => setTimeout(resolve, 60));
412 });
413 const listFrame = pendingFrame;
414 await act(async () => {
415 listFrame?.(performance.now());
416 await flushTimers();
417 });
418 eq(rootEl.textContent, firstSearch, "a later list item commits the previous tight item into the parsed prefix");
419
420 await act(async () => root.unmount());
421 dom.window.close();
422 }
423
424 {
425 const dom = installDom();
426 const rootEl = document.getElementById("root");
427 if (!rootEl) throw new Error("missing root");
428 let pendingIdle: (() => void) | undefined;
429 Object.defineProperty(dom.window, "requestIdleCallback", {
430 configurable: true,
431 value: (callback: () => void) => {
432 pendingIdle = callback;
433 return 1;
434 },
435 });
436 Object.defineProperty(dom.window, "cancelIdleCallback", {
437 configurable: true,
438 value: () => {
439 pendingIdle = undefined;
440 },
441 });
442 Object.defineProperty(dom.window, "setTimeout", {
443 configurable: true,
444 value: (callback: TimerHandler) => {
445 if (typeof callback === "function") callback();
446 return 1;
447 },
448 });
449 Object.defineProperty(dom.window, "clearTimeout", {
450 configurable: true,
451 value: () => undefined,
452 });
453
454 const root = createRoot(rootEl);
455 const streamed = "a".repeat(8_100);
456 const finalText = `${streamed} final`;
457 function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
458 return <div>{useRenderedMarkdownText(text, streaming)}</div>;
459 }
460 await act(async () => {
461 root.render(<MarkdownTextProbe text={streamed} streaming />);
462 await flushTimers();
463 });
464 await act(async () => {
465 root.render(<MarkdownTextProbe text={finalText} streaming={false} />);
466 await flushTimers();
467 });
468 eq(rootEl.textContent, streamed, "large Markdown keeps its committed content while finalization waits for idle");
469 ok(Boolean(pendingIdle), "large Markdown schedules one idle finalization callback");
470
471 await act(async () => {
472 pendingIdle?.();
473 await flushTimers();
474 });
475 eq(rootEl.textContent, finalText, "idle finalization commits the complete large Markdown text");
476
477 await act(async () => root.unmount());
478 dom.window.close();
479 }
480
481 {
482 const dom = installDom();
483 installDesktopHostStub({});
484 const dirtySvg = `
485 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onload="steal()">
486 <script>alert(1)</script>
487 <a id="safe" href="https://example.com/diagram"><text>safe</text></a>
488 <a id="unsafe" href="javascript:alert(1)"><text>bad</text></a>
489 <a id="unsafe-xlink" xlink:href="data:text/html,boom"><text>bad</text></a>
490 <image id="remote-image" href="https://images.example.com/diagram.png" />
491 <use id="external-use" href="https://images.example.com/icons.svg#node" />
492 <g onclick="steal()"><text>node</text></g>
493 </svg>`;
494 const sanitized = sanitizeMermaidSvg(dirtySvg);
495 const doc = parseSvg(sanitized);
496
497 ok(!doc.documentElement.hasAttribute("onload"), "sanitizer strips event attributes from the root SVG");
498 ok(!doc.querySelector("script"), "sanitizer removes script nodes");
499 ok(doc.querySelector("#safe")?.getAttribute("href") === "https://example.com/diagram", "sanitizer keeps safe external links");
500 ok(!doc.querySelector("#unsafe")?.hasAttribute("href"), "sanitizer removes javascript links");
501 ok(!doc.querySelector("#unsafe-xlink")?.hasAttribute("xlink:href"), "sanitizer removes data xlink links");
502 ok(
503 doc.querySelector("#remote-image")?.getAttribute("href")?.startsWith(`${REMOTE_MARKDOWN_IMAGE_PATH}?url=`) === true,
504 "sanitizer routes Mermaid image resources through the backend proxy",
505 );
506 ok(!doc.querySelector("#external-use")?.hasAttribute("href"), "sanitizer removes external SVG use resources");
507 ok(!doc.querySelector("g")?.hasAttribute("onclick"), "sanitizer strips event attributes from child nodes");
508 ok(isSafeMermaidHref("https://example.com/a"), "https Mermaid links are safe");
509 ok(isSafeMermaidHref("mailto:hello@example.com"), "mailto Mermaid links are safe");
510 ok(!isSafeMermaidHref("file:///tmp/private"), "file Mermaid links are not safe");
511 ok(!isOpenableMermaidHref("#internal"), "fragment Mermaid links are not opened externally");
512
513 dom.window.close();
514 }
515
516 {
517 const dom = installDom();
518 const openedUrls: string[] = [];
519 dom.window.open = ((url: string | URL | undefined) => {
520 if (url) openedUrls.push(String(url));
521 return null;
522 }) as Window["open"];
523
524 const renders: Array<{ definition: string; theme: string }> = [];
525 const panZoomCalls: string[] = [];
526 const frames = new Map<number, FrameRequestCallback>();
527 let frameId = 0;
528 dom.window.requestAnimationFrame = (callback) => {
529 frames.set(++frameId, callback);
530 return frameId;
531 };
532 dom.window.cancelAnimationFrame = (id) => { frames.delete(id); };
533 const advanceFrame = async () => {
534 await act(async () => {
535 const current = [...frames.entries()];
536 for (const [id, callback] of current) {
537 if (!frames.delete(id)) continue;
538 callback(dom.window.performance.now());
539 }
540 });
541 };
542
543 __setMermaidRenderAdapterForTest(async (_svgId, definition, theme, signal) => {
544 if (signal.aborted) throw new DOMException("Aborted", "AbortError");
545 renders.push({ definition, theme });
546 return `
547 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 80" onload="steal()">
548 <script>alert(1)</script>
549 <a id="safe-link" href="https://example.com/diagram"><text>Open</text></a>
550 <a id="unsafe-link" href="vbscript:msgbox(1)"><text>Blocked</text></a>
551 <g class="node"><text>Rendered Mermaid</text></g>
552 </svg>`;
553 });
554
555 __setMermaidPanZoomFactoryForTest(() => ({
556 destroy: () => { panZoomCalls.push("destroy"); },
557 resize: () => { panZoomCalls.push("resize"); },
558 fit: () => { panZoomCalls.push("fit"); },
559 center: () => { panZoomCalls.push("center"); },
560 zoomIn: () => { panZoomCalls.push("zoomIn"); },
561 zoomOut: () => { panZoomCalls.push("zoomOut"); },
562 reset: () => { panZoomCalls.push("reset"); },
563 }));
564
565 const rootEl = document.getElementById("root");
566 if (!rootEl) throw new Error("missing root");
567 const root = createRoot(rootEl);
568
569 await act(async () => {
570 root.render(
571 <LocaleProvider>
572 <div className="chat-pane">
573 <MermaidDiagram definition={"graph TD\nA-->B"} />
574 </div>
575 </LocaleProvider>,
576 );
577 await flushTimers();
578 });
579
580 await waitFor("Mermaid preview SVG rendered in DOM", () => Boolean(document.querySelector(".mermaid-diagram__preview svg")));
581 ok(document.querySelector(".mermaid-diagram__toolbar"), "Mermaid renderer shows its toolbar");
582 eq(renders.length, 1, "Mermaid renderer calls the render adapter once");
583 eq(renders[0]?.definition, "graph TD\nA-->B", "Mermaid renderer passes the diagram definition to Mermaid");
584 ok(document.querySelector("#safe-link"), "safe SVG link remains in the rendered DOM");
585 ok(!document.querySelector("#unsafe-link")?.hasAttribute("href"), "unsafe SVG link href is stripped in the rendered DOM");
586 ok(!document.querySelector(".mermaid-diagram__preview svg")?.hasAttribute("onload"), "rendered SVG root event handler is stripped");
587 ok(!document.querySelector(".mermaid-diagram__preview script"), "rendered SVG script nodes are removed");
588
589 // Initialization and layout sync own separate animation frames. Advance
590 // those frames explicitly instead of racing JSDOM's 60 Hz clock with timers.
591 eq(panZoomCalls.length, 0, "pan zoom waits for its initialization frame");
592 await advanceFrame();
593 ok(!panZoomCalls.includes("fit"), "pan zoom layout waits for the following frame");
594 await advanceFrame();
595 ok(panZoomCalls.includes("fit") && panZoomCalls.includes("center"), "pan zoom instance initialized");
596
597 const zoomIn = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]');
598 const zoomOut = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]');
599 const reset = document.querySelector<HTMLButtonElement>('button[aria-label="Reset zoom"]');
600 zoomIn?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
601 zoomOut?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
602 reset?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
603 ok(panZoomCalls.includes("zoomIn"), "zoom in button calls the pan zoom instance");
604 ok(panZoomCalls.includes("zoomOut"), "zoom out button calls the pan zoom instance");
605 ok(panZoomCalls.includes("reset"), "reset zoom button calls the pan zoom instance");
606
607 document.querySelector("#safe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
608 eq(openedUrls[0], "https://example.com/diagram", "SVG links open through the external browser bridge");
609 document.querySelector("#unsafe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
610 eq(openedUrls.length, 1, "unsafe SVG links do not open externally");
611
612 await act(async () => {
613 document.querySelector<HTMLButtonElement>('button[aria-label="Show diagram source"]')
614 ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
615 await flushTimers();
616 });
617 ok(document.querySelector(".mermaid-diagram__code")?.textContent?.includes("graph TD"), "source tab shows the Mermaid definition");
618
619 await act(async () => {
620 document.querySelector<HTMLButtonElement>('button[aria-label="Open fullscreen"]')
621 ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
622 await flushTimers();
623 });
624 ok(document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "fullscreen diagram portals into the chat pane");
625
626 await act(async () => {
627 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
628 await flushTimers();
629 });
630 ok(!document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "Escape closes the Mermaid fullscreen portal");
631
632 await act(async () => {
633 root.unmount();
634 });
635 __setMermaidRenderAdapterForTest(null);
636 __setMermaidPanZoomFactoryForTest(undefined);
637 dom.window.close();
638 }
639
640 {
641 eq(splitStreamingTailFence("still typing"), null, "a plain tail has no code fence split");
642 eq(splitStreamingTailFence("```\nc\n```\nafter"), null, "a closed fence leaves no open code tail");
643 const split = splitStreamingTailFence("```js\nconst a = 1;\nconst b");
644 eq(split?.head, "", "an open fence at the tail start has no plain head");
645 eq(split?.lang, "js", "the open fence split keeps the info-string language");
646 eq(split?.code, "const a = 1;\nconst b", "the open fence split drops the opener line from the code body");
647 eq(splitStreamingTailFence("para\n\n```\nx")?.head, "para\n\n", "text before the open fence stays plain");
648 }
649
650 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
651 if (failed > 0) process.exit(1);
652
652 lines Plain Text