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