| 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 |