" in out assert '' in out def test_latex_math_in_bold_renders() -> None: out = _render(r"Then **\(x^2\)** end.") assert "" in out assert '' in out def test_mixed_tex_and_latex_styles() -> None: """Only \\(...\\) renders; the $...$ form is left as raw prose (see test_single_dollar_inline_math_is_not_supported).""" out = _render(r"Here $x$ then \(y\) end.") assert out.count('') == 1 assert "[KATEX:y:inline]" in out assert "$x$" in out # untouched def test_latex_math_inside_inline_code_preserved() -> None: r"""\(...\) inside inline code must NOT render as math — code is escaped + left literal.""" out = _render(r"Code: `\(x\)` raw.") assert r"\(x\)" in out assert '' not in out def test_latex_math_inside_fenced_code_preserved() -> None: r"""\(...\) inside a fenced block must stay literal.""" out = _render("```\nA \\(x\\) sample\n```") assert "" in out assert '' not in out def test_solo_escaped_bracket_does_not_render_as_math() -> None: r"""A lone \[ with no matching \] is not math — it's a markdown bracket escape. Don't hijack it.""" out = _render(r"No math: \[ alone.") assert '' not in out def test_markdown_link_unaffected_by_math_protection() -> None: r"""Math regex uses \[ / \] (escaped brackets), not bare [...]. Markdown links must still render.""" out = _render("See [docs](https://example.com).") assert 'docs" in out # --------------------------------------------------------------------------- # Edge cases — Copilot review on PR #425 # --------------------------------------------------------------------------- def test_display_math_inside_inline_code_stays_literal() -> None: r"""``$$...$$`` inside backticks must NOT trigger display-math extraction — otherwise the math sentinel ends up wrapped inside theplaceholder and leaks into rendered HTML as a raw null-byte sentinel string. Pre-#425 ordering ran display-math before inline code, which caused this leak. The reordering makes inline code seal first. """ out = _render(r"Use `$$x$$` for display math.") assert "$$x$$" in out assert '' not in out assert "\x00" not in out # no leaked sentinel def test_latex_display_math_inside_inline_code_stays_literal() -> None: r"""Same as above, but for the LaTeX-style \[...\] delimiter.""" out = _render(r"Use `\[x\]` for display math.") assert r"\[x\]" in out assert '' not in out assert "\x00" not in out def test_inline_latex_math_does_not_span_paragraphs() -> None: r"""An unterminated \(...\) on one line must not eat the following paragraph until it finds a closing \) — that would consume large chunks of text under streaming markdown where the closer hasn't arrived yet. Mirrors the $...$ behavior.""" src = "Open \\(unterminated\n\nNext paragraph with \\(x\\) here." out = _render(src) # The bare \( on line 1 should NOT match; the well-formed \(x\) # on the second paragraph should render normally. assert out.count('') == 1 assert "[KATEX:x:inline]" in out # The "unterminated" stays as raw text. assert "unterminated" in out def test_dollar_signs_never_render_as_math_across_paragraphs() -> None: """Pre-removal regression covered the cross-paragraph eating bug for $...$. With single-$ inline math gone, the stronger guarantee is simply that no arrangement of $ signs ever produces math.""" src = "Open $unterminated\n\nNext paragraph $x$ here." out = _render(src) assert '' not in out assert "$unterminated" in out assert "$x$" in out # --------------------------------------------------------------------------- # Mermaid progressive rendering — source-keyed SVG cache # --------------------------------------------------------------------------- _MERMAID_HARNESS_TEMPLATE = """ const vm = require('vm'); // Minimal DOM fake — enough surface for postRenderMermaid + the // mermaid render path. Each created element tracks its attributes, // classList, children, and parent so replaceWith works. function makeEl(tag) { const el = { tagName: tag.toUpperCase(), _attrs: {}, _classes: new Set(), children: [], parent: null, _innerHTML: '', _textContent: '', setAttribute(k, v) { this._attrs[k] = v; }, getAttribute(k) { return this._attrs[k] !== undefined ? this._attrs[k] : null; }, get classList() { // Real DOMTokenList is array-like (length + indexed access) AND // exposes add/remove/contains. The hljs language-extraction // loop reads .length + [j], so we return a fresh Array snapshot // each get + bolt the mutator methods on. add/remove operate on // the live _classes set so subsequent reads see updates. const self = this; const arr = Array.from(self._classes); arr.add = (...c) => c.forEach((x) => self._classes.add(x)); arr.remove = (...c) => c.forEach((x) => self._classes.delete(x)); arr.contains = (c) => self._classes.has(c); return arr; }, get className() { return Array.from(this._classes).join(' '); }, set className(v) { this._classes = new Set(String(v).split(/\\s+/).filter(Boolean)); }, get textContent() { return this._textContent || this.children.map(c => c.textContent || '').join(''); }, set textContent(v) { // Real DOM: assigning textContent ALSO replaces innerHTML with // an entity-escaped representation of the same text. escapeHtml // (utils.js) round-trips via this side effect — without it, // every escapeHtml() call returns '' and renderMarkdown emits // emptytags. this._textContent = v; this.children = []; this._innerHTML = String(v) .replace(/&/g, '&') .replace(//g, '>'); }, get innerHTML() { return this._innerHTML; }, set innerHTML(v) { // Real DOM invalidates the previous textContent when innerHTML // is replaced — leaving _textContent intact would return stale // data from subsequent textContent reads and mask bugs that // depend on innerHTML/textContent consistency. We don't HTML- // parse here, so the cheap correct behavior is to clear // _textContent and let the children-derived fallback in the // textContent getter (which is empty after this children = []) // take over. this._innerHTML = v; this.children = []; this._textContent = ''; }, get isConnected() { // In real DOM this checks attachment to the document; for the // test harness we approximate via the parent chain. After // replaceWith, the displaced element's parent is nulled so // its isConnected goes false — which is exactly the // detached-during-streaming case the production guard // protects against. return !!this.parent; }, appendChild(c) { c.parent = this; this.children.push(c); return c; }, closest(selector) { const t = selector.toUpperCase(); let cur = this; while (cur) { if (cur.tagName === t) return cur; cur = cur.parent; } return null; }, replaceWith(other) { if (!this.parent) return; const idx = this.parent.children.indexOf(this); if (idx === -1) return; this.parent.children[idx] = other; other.parent = this.parent; this.parent = null; }, querySelectorAll(selector) { // Supports the two selectors the post-render passes use: // "pre code.language-mermaid" (postRenderMermaid) // "pre code[class*='language-']" (postRenderHljs) const out = []; const wantsMermaid = selector === "pre code.language-mermaid"; function matchesLangAttr(el) { for (const cls of el._classes) { if (cls.startsWith('language-')) return true; } return false; } function walk(node) { for (const c of (node.children || [])) { const isCodeInPre = c.tagName === 'CODE' && c.parent && c.parent.tagName === 'PRE'; if (isCodeInPre) { if (wantsMermaid) { if (c._classes.has('language-mermaid')) out.push(c); } else if (matchesLangAttr(c)) { out.push(c); } } walk(c); } } walk(this); return out; }, }; return el; } global.document = { createElement: makeEl, addEventListener: () => {}, getElementById: () => null, head: { appendChild: () => {} }, documentElement: {}, }; global.window = global; global.getComputedStyle = () => ({ getPropertyValue: () => '' }); let renderCallCount = 0; let renderShouldFail = false; global.mermaid = { initialize: () => {}, render: (id, source) => { renderCallCount++; if (renderShouldFail) { return Promise.reject(new Error('bad diagram: ' + source)); } return Promise.resolve({ svg: '', bindFunctions: null, }); }, }; // hljs stub. highlightElement mutates the element in place: replaces // innerHTML with a deterministic synthetic span keyed by the source, // and adds the hljs class — same surface postRenderHljs depends on. // hljsHighlightCallCount lets tests assert "ran N times" semantics. let hljsHighlightCallCount = 0; global.hljs = { configure: () => {}, highlightElement: (el) => { hljsHighlightCallCount++; el._classes.add('hljs'); el._innerHTML = '' + el._textContent + ''; }, }; vm.runInThisContext(%(utils_src)s); vm.runInThisContext(%(renderer_src)s); // Mermaid is normally lazy-loaded via _loadMermaid which fetches a // script tag. Force-mark it ready so postRenderMermaid invokes the // render path synchronously without trying to inject a script. (This // poke is WHY the harness script-evaluates the demodulized source: a // real module would encapsulate _mermaidState.) _mermaidState = 'ready'; %(scenario)s """ def _run_mermaid_scenario(scenario_js: str) -> dict[str, Any]: """Run a JS snippet against the mermaid-aware harness, return JSON output.""" harness = _MERMAID_HARNESS_TEMPLATE % { "utils_src": json.dumps(_demodulize(_UTILS_JS)), "renderer_src": json.dumps(_demodulize(_RENDERER_JS)), "scenario": scenario_js, } result = subprocess.run( ["node", "-e", harness], capture_output=True, text=True, timeout=10, check=True, ) parsed: dict[str, Any] = json.loads(result.stdout) return parsed def _build_mermaid_container_js(sources: list[str]) -> str: """JS expression that builds a container with ``
`` blocks.""" src_array = "[" + ", ".join(json.dumps(s) for s in sources) + "]" return f""" function buildContainer(sources) {{ const container = document.createElement('div'); for (const src of sources) {{ const pre = document.createElement('pre'); const code = document.createElement('code'); code.classList.add('language-mermaid'); code.textContent = src; pre.appendChild(code); container.appendChild(pre); }} return container; }} const sources = {src_array}; const container = buildContainer(sources); """ # Drain microtasks + global mermaid render chain. Wraps the async # work in a setTimeout(0) hop so all queued microtasks (including # the per-source pending list draining via _mermaidRenderChain) # flush before the assertion script reads cache state. _MERMAID_DRAIN_JS = """ function drainAndReport(report) { // Two setTimeout hops give the global chain time to resolve // mermaid.render's promise + the .then handlers that populate // the cache and call _applyMermaidSvg. setTimeout(() => setTimeout(() => { process.stdout.write(JSON.stringify(report())); }, 0), 0); } """ def test_mermaid_cache_hit_skips_render_call() -> None: """Identical source on a second postRenderMermaid call must serve from the cache — mermaid.render runs exactly once across both invocations. This is the core invariant that lets streamingRender fire postRenderMermaid on every rAF tick without thrashing.""" scenario = ( _build_mermaid_container_js(["graph TD\n A --> B"]) + _MERMAID_DRAIN_JS + """ postRenderMermaid(container); setTimeout(() => setTimeout(() => { // Second invocation — fresh container, same source. Should NOT // call mermaid.render again because the cache holds the SVG. const container2 = buildContainer(sources); postRenderMermaid(container2); setTimeout(() => { process.stdout.write(JSON.stringify({ renderCalls: renderCallCount, cacheSize: _mermaidSvgCache.size, firstClass: container.children[0].className, secondClass: container2.children[0].className, })); }, 0); }, 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["renderCalls"] == 1, "second postRenderMermaid call invoked render — cache miss" assert out["cacheSize"] == 1 # Both containers end up with the rendered class — second from cache. assert "mermaid-rendered" in out["firstClass"] assert "mermaid-rendered" in out["secondClass"] def test_mermaid_distinct_sources_render_independently() -> None: """Two distinct sources each trigger mermaid.render once and are cached separately. Verifies the cache key is the source string, not e.g. a positional index.""" scenario = ( _build_mermaid_container_js(["graph TD\n A --> B", "sequenceDiagram\n A->>B: hi"]) + """ postRenderMermaid(container); // Drain twice — across-source serialization means the second // render starts only after the first lands. setTimeout(() => setTimeout(() => setTimeout(() => { process.stdout.write(JSON.stringify({ renderCalls: renderCallCount, cacheSize: _mermaidSvgCache.size, })); }, 0), 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["renderCalls"] == 2 assert out["cacheSize"] == 2 def test_mermaid_error_cached_to_avoid_thrash() -> None: """A mermaid render failure caches the error message keyed by source, so subsequent postRenderMermaid calls on the same source don't re-invoke mermaid.render only to re-fail.""" scenario = ( _build_mermaid_container_js(["bogus diagram"]) + """ renderShouldFail = true; postRenderMermaid(container); setTimeout(() => setTimeout(() => { // Re-run with same source — should hit error cache. const container2 = buildContainer(sources); postRenderMermaid(container2); setTimeout(() => { process.stdout.write(JSON.stringify({ renderCalls: renderCallCount, errorCacheSize: _mermaidErrorCache.size, svgCacheSize: _mermaidSvgCache.size, secondClass: container2.children[0].className, })); }, 0); }, 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["renderCalls"] == 1, "errored source re-invoked mermaid.render — error cache miss" assert out["errorCacheSize"] == 1 assert out["svgCacheSize"] == 0 # Second container shows the error class without re-rendering. assert "mermaid-error" in out["secondClass"] def test_mermaid_cache_evicts_oldest_at_cap() -> None: """FIFO eviction at _MERMAID_CACHE_MAX prevents unbounded growth on long sessions emitting many distinct diagrams.""" scenario = """ const cap = _MERMAID_CACHE_MAX; for (let i = 0; i < cap + 5; i++) { _cacheFifoEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null}, cap); } process.stdout.write(JSON.stringify({ size: _mermaidSvgCache.size, hasOldest: _mermaidSvgCache.has('src-0'), hasNewest: _mermaidSvgCache.has('src-' + (cap + 4)), })); """ out = _run_mermaid_scenario(scenario) assert out["size"] == 64 assert out["hasOldest"] is False assert out["hasNewest"] is True def test_mermaid_overwrite_does_not_evict() -> None: """Overwriting an existing key is an in-place update, not a new insertion — should not evict the oldest entry. Pre-fix, an update at cap would unnecessarily drop an unrelated cached SVG.""" scenario = """ const cap = _MERMAID_CACHE_MAX; // Fill exactly to cap. for (let i = 0; i < cap; i++) { _cacheFifoEntry(_mermaidSvgCache, 'src-' + i, {svg: 'svg-' + i, bindFunctions: null}, cap); } // Overwrite an existing entry — must not evict src-0. _cacheFifoEntry(_mermaidSvgCache, 'src-5', {svg: 'svg-updated', bindFunctions: null}, cap); process.stdout.write(JSON.stringify({ size: _mermaidSvgCache.size, hasOldest: _mermaidSvgCache.has('src-0'), updated: _mermaidSvgCache.get('src-5').svg, })); """ out = _run_mermaid_scenario(scenario) assert out["size"] == 64 assert out["hasOldest"] is True, "overwrite evicted oldest unnecessarily" assert out["updated"] == "svg-updated" def test_mermaid_cache_cleared_on_init() -> None: """_initMermaid must clear both caches so a theme change via reRenderAllMermaid doesn't serve stale SVG keyed by source-only — the rendered output depends on themeVariables which change on init.""" scenario = """ _cacheFifoEntry(_mermaidSvgCache, 'src-1', {svg: 'old', bindFunctions: null}, _MERMAID_CACHE_MAX); _cacheFifoEntry(_mermaidErrorCache, 'src-bad', 'old error', _MERMAID_CACHE_MAX); _initMermaid(); process.stdout.write(JSON.stringify({ svgSize: _mermaidSvgCache.size, errorSize: _mermaidErrorCache.size, })); """ out = _run_mermaid_scenario(scenario) assert out["svgSize"] == 0 assert out["errorSize"] == 0 def test_mermaid_cache_hit_reapplies_bind_functions() -> None: """bindFunctions returned by mermaid.render attach link/click handlers to the rendered SVG. Cache hits must re-invoke this on the new container instance — pre-fix, only the first render got bindings; subsequent cache hits via innerHTML left the SVG inert.""" scenario = ( _build_mermaid_container_js(["graph TD\n A --> B"]) + """ let bindCallCount = 0; const origRender = mermaid.render; mermaid.render = (id, source) => { return Promise.resolve({ svg: '', bindFunctions: () => { bindCallCount++; }, }); }; postRenderMermaid(container); setTimeout(() => setTimeout(() => { // Second invocation — cache hit, should still call // bindFunctions on the new container. const container2 = buildContainer(sources); postRenderMermaid(container2); setTimeout(() => { process.stdout.write(JSON.stringify({ bindCallCount: bindCallCount, })); }, 0); }, 0), 0); """ ) out = _run_mermaid_scenario(scenario) # First render binds; cache hit on second container also binds. assert out["bindCallCount"] == 2, ( "bindFunctions was not re-applied on cache hit — interactive " "diagram features (links, callbacks) would silently break" ) def test_streaming_render_invokes_mermaid_post_render() -> None: """_streamingRenderApply must call postRenderMermaid so closed mermaid fences appear progressively during streaming, not only at stream_end via streamingRenderFinalize.""" body = _RENDERER_JS.read_text(encoding="utf-8") # Bound the search to a window after the function declaration — # avoids the brittleness of stopping at the first inner-block # closing brace. start = body.index("function _streamingRenderApply") mermaid_call = body.find("postRenderMermaid(el)", start, start + 4000) assert mermaid_call != -1, ( "_streamingRenderApply must call postRenderMermaid for " "progressive diagram rendering during streaming" ) # --------------------------------------------------------------------------- # _normalizeMermaidSource — autoquote labels with bare shape-delimiter # chars. Mermaid rejects unquoted ( ) [ ] { } inside other labels with # a "got 'PS'" parse error (paren-start in shape context). The two # diagrams in the screenshot regression case are encoded here verbatim. # --------------------------------------------------------------------------- def _run_normalize(source: str) -> str: """Drive _normalizeMermaidSource against the JS harness and return its output. The function is pure, so no container / mermaid stub setup is required.""" scenario = f""" const input = {json.dumps(source)}; const output = _normalizeMermaidSource(input); process.stdout.write(JSON.stringify({{ output: output }})); """ out = _run_mermaid_scenario(scenario) return str(out["output"]) # Diagram 1 from the screenshot regression — unquoted edge labels with # parens and
markers. Mermaid rejects both edge labels with # "got 'PS'"; quoting them resolves it. _SCREENSHOT_DIAGRAM_1_IN = ( "flowchart LR\n" ' A["vllm-openai:nightly
commit 5536fc0c0
2026-05-11 11:59"]' " -->|22 upstream
main commits
(10 csrc, but
no new bindings)|" ' B["fork merge_base
7863fff6e5
2026-05-12 00:27"]\n' " B -->|13 jasl patches
(Python only:
tunings, kernels," "
warmup, etc.)|" ' C["ds4-sm120-preview-dev
acc3455b1e"]' ) _SCREENSHOT_DIAGRAM_1_OUT = ( "flowchart LR\n" ' A["vllm-openai:nightly
commit 5536fc0c0
2026-05-11 11:59"]' ' -->|"22 upstream
main commits
(10 csrc, but
no new bindings)"|' ' B["fork merge_base
7863fff6e5
2026-05-12 00:27"]\n' ' B -->|"13 jasl patches
(Python only:
tunings, kernels,' '
warmup, etc.)"|' ' C["ds4-sm120-preview-dev
acc3455b1e"]' ) # Diagram 2 from the screenshot regression — unquoted RECTANGLE node # label `D[untouched
(.so, _version.py,
install-vendored)]`. # Same parser failure mode; quoting the bracket label fixes it. _SCREENSHOT_DIAGRAM_2_IN = ( "flowchart LR\n" " A[nightly's vllm/
installed package] --> B{tar -xf
fork-vllm.tar}\n" " B -->|in archive| C[overwritten with
fork's version]\n" " B -->|not in archive| D[untouched
(.so, _version.py," "
install-vendored)]\n" " E[explicit rm of 1 file
deleted upstream] --> B" ) _SCREENSHOT_DIAGRAM_2_OUT = ( "flowchart LR\n" " A[nightly's vllm/
installed package] --> B{tar -xf
fork-vllm.tar}\n" " B -->|in archive| C[overwritten with
fork's version]\n" ' B -->|not in archive| D["untouched
(.so, _version.py,' '
install-vendored)"]\n' " E[explicit rm of 1 file
deleted upstream] --> B" ) @pytest.mark.parametrize( ("source", "expected"), [ (_SCREENSHOT_DIAGRAM_1_IN, _SCREENSHOT_DIAGRAM_1_OUT), (_SCREENSHOT_DIAGRAM_2_IN, _SCREENSHOT_DIAGRAM_2_OUT), ], ) def test_mermaid_autoquote_fixes_screenshot_diagrams(source: str, expected: str) -> None: """The two exact diagrams from the screenshot regression. If these stop being rewritten with quoted labels, mermaid will again reject them with `Expecting ... got 'PS'` during live streaming.""" assert _run_normalize(source) == expected @pytest.mark.parametrize( "source", [ # Clean diagram — no shape delimiters in any label. "graph TD\n A[foo] --> B[bar]", # Edge label with no special chars. "A --> B\nA -->|plain text| B", # Already-correctly-quoted node label. 'A["already (quoted)"] --> B', # Already-correctly-quoted edge label. 'A -->|"already (quoted)"| B', # Cylinder shape — inner () is part of the shape syntax. "A[(database)] --> B", # Subroutine shape — inner [] is part of the shape syntax. "A[[subroutine]] --> B", # Trapezoid shape — inner / is part of the shape syntax. "A[/trapezoid/] --> B", # Reverse trapezoid. "A[\\trap\\] --> B", # Mermaid directive — braces here are config, not a label. '%%{init: {"theme": "dark"}}%%\ngraph TD\n A --> B', #
tags on their own don't trip quoting. "A[line1
line2] --> B", # Sequence diagram — different grammar; we only target labels # in shape/edge syntax that match the regex anchors. "sequenceDiagram\n A->>B: hello", ], ) def test_mermaid_autoquote_leaves_valid_source_alone(source: str) -> None: """The autoquoter must not rewrite syntactically valid Mermaid — a false positive here would break a working diagram. Each case covers a syntax form whose delimiters are intentional and must not be wrapped.""" assert _run_normalize(source) == source def test_mermaid_autoquote_edge_label_with_parens() -> None: """Bare-parens edge label gets wrapped. The bare `(` would otherwise re-enter Mermaid's shape parser.""" src = "A -->|note (with parens)| B" assert _run_normalize(src) == 'A -->|"note (with parens)"| B' def test_mermaid_autoquote_node_label_with_parens() -> None: """Bare-parens node label gets wrapped.""" src = "D[label (foo, bar)]" assert _run_normalize(src) == 'D["label (foo, bar)"]' def test_mermaid_autoquote_node_label_with_braces() -> None: """Bare-braces in a rectangle label get wrapped. (Diamond {} shapes are left alone — only single-bracket [] labels are rewritten.)""" src = "A[config {key: value}]" assert _run_normalize(src) == 'A["config {key: value}"]' def test_mermaid_autoquote_preserves_br_tag_with_parens() -> None: """`
` inside a label that also has parens stays — only the quoting needs to be added around the whole label.""" src = "A[line1
(line2)] --> B" assert _run_normalize(src) == 'A["line1
(line2)"] --> B' def test_mermaid_autoquote_skips_label_with_internal_quote() -> None: """If a label contains a literal `"`, wrapping would produce nested unescaped quotes. The autoquoter must punt — leaving the parse error to surface, rather than silently producing a worse one.""" src = 'A[he said "hi" (lol)]' assert _run_normalize(src) == src def test_mermaid_autoquote_multiple_edges_on_one_line() -> None: """Both edge labels on a single line get rewritten independently.""" src = "A -->|first (paren)| B -->|second (paren)| C" expected = 'A -->|"first (paren)"| B -->|"second (paren)"| C' assert _run_normalize(src) == expected def test_mermaid_autoquote_normalized_source_hits_cache() -> None: """The SVG cache keys on the normalized source — same malformed input that the LLM streamed earlier still hits the cache on re-render rather than re-invoking mermaid.render every tick.""" bad = "A[label (with parens)] --> B" scenario = ( _build_mermaid_container_js([bad]) + _MERMAID_DRAIN_JS + """ postRenderMermaid(container); setTimeout(() => setTimeout(() => { const container2 = buildContainer(sources); postRenderMermaid(container2); setTimeout(() => { process.stdout.write(JSON.stringify({ renderCalls: renderCallCount, normalized: container.children[0]._attrs['data-mermaid-source'], })); }, 0); }, 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["renderCalls"] == 1, "second render bypassed the cache" assert out["normalized"] == 'A["label (with parens)"] --> B' def test_mermaid_normalize_memo_populates_on_first_call() -> None: """First postRenderMermaid call populates _mermaidNormalizeCache with a raw→normalized entry. A second call on identical raw textContent then hits the memo (size stays at 1, no second normalize call), which is the perf-1 fix — avoids re-running split + per-line regex per rAF tick when the diagram hasn't changed.""" bad = "A[label (with parens)] --> B" scenario = ( _build_mermaid_container_js([bad]) + _MERMAID_DRAIN_JS + """ postRenderMermaid(container); const sizeAfterFirst = _mermaidNormalizeCache.size; const cachedNorm = _mermaidNormalizeCache.get(sources[0]); // Re-render on a fresh container with the same source. const container2 = buildContainer(sources); postRenderMermaid(container2); setTimeout(() => setTimeout(() => { process.stdout.write(JSON.stringify({ sizeAfterFirst: sizeAfterFirst, cachedNorm: cachedNorm, sizeAfterSecond: _mermaidNormalizeCache.size, })); }, 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["sizeAfterFirst"] == 1, "first call didn't populate normalize memo" assert out["cachedNorm"] == 'A["label (with parens)"] --> B' assert out["sizeAfterSecond"] == 1, ( "second call added a new entry — memo missed on identical source" ) def test_mermaid_normalize_memo_is_consulted_before_normalize() -> None: """Pre-seed _mermaidNormalizeCache with a sentinel value for a raw source. postRenderMermaid must use the sentinel rather than re-running _normalizeMermaidSource. Catches a regression where the memo gets populated but the lookup path is skipped.""" bad = "A[label (with parens)] --> B" sentinel = "SENTINEL_FROM_MEMO --> X" raw_js = json.dumps(bad) sentinel_js = json.dumps(sentinel) scenario = ( _build_mermaid_container_js([bad]) + _MERMAID_DRAIN_JS + f""" _mermaidNormalizeCache.set({raw_js}, {sentinel_js}); postRenderMermaid(container); setTimeout(() => setTimeout(() => {{ process.stdout.write(JSON.stringify({{ sourceAttr: container.children[0]._attrs['data-mermaid-source'], }})); }}, 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["sourceAttr"] == sentinel, ( "postRenderMermaid bypassed the normalize memo and re-ran normalize" ) def test_mermaid_normalize_memo_distinct_sources_cache_separately() -> None: """Two distinct raw sources produce two memo entries. Confirms the memo keys on raw textContent, not on something coarser like container identity.""" bad1 = "A[label (with parens)] --> B" bad2 = "C[other (label)] --> D" scenario = ( _build_mermaid_container_js([bad1, bad2]) + _MERMAID_DRAIN_JS + """ postRenderMermaid(container); setTimeout(() => setTimeout(() => { process.stdout.write(JSON.stringify({ size: _mermaidNormalizeCache.size, hasBad1: _mermaidNormalizeCache.has(sources[0]), hasBad2: _mermaidNormalizeCache.has(sources[1]), })); }, 0), 0); """ ) out = _run_mermaid_scenario(scenario) assert out["size"] == 2 assert out["hasBad1"] is True assert out["hasBad2"] is True # --------------------------------------------------------------------------- # Code-fence pairing — close requires \n / EOS, content can't cross # another close-pattern. Repros the streaming bug where ```mermaid + # later ```python were paired by the regex, handing mermaid a # truncated source. # --------------------------------------------------------------------------- def _render_md(source: str) -> str: """Drive renderMarkdown against the JS harness and return the rendered HTML. The function is a pure string transform; no DOM container scaffolding is required.""" scenario = f""" const input = {json.dumps(source)}; const output = renderMarkdown(input); process.stdout.write(JSON.stringify({{ output: output }})); """ out = _run_mermaid_scenario(scenario) return str(out["output"]) _FENCE = "```" def test_fence_partial_open_emits_no_code_block() -> None: """While a fence is still open and there's no other ``` later in the buffer, noblock is emitted — the open fence stays as plain markdown text until the real close arrives.""" src = "Intro\n" + _FENCE + 'mermaid\nA["x"] -->|note (with parens)| B["y"]\nstill streaming' html = _render_md(src) assert "mid-stream: {html!r}" def test_fence_partial_with_later_open_does_not_pair_wrongly() -> None: """Before the fence-pair fix: an unclosed ```mermaid followed by a ```python (also unclosed) would have paired up as...python..., handing mermaid a truncated source. With the new regex, neither fence emits a block until its OWN closing line arrives.""" src = "Intro\n" + _FENCE + "mermaid\nA --> B\n" + _FENCE + 'python\nprint("hi")' html = _render_md(src) assert 'class="language-mermaid"' not in html, ( f"mermaid fence should not emit while open: {html!r}" ) assert 'class="language-python"' not in html, ( f"python fence should not emit while open: {html!r}" ) def test_fence_close_paired_with_next_open_is_rejected() -> None: """Repro of the live-streaming failure: mermaid fence open, then ```python opens and ``` closes the python block. Without the fix, the regex paired mermaid's open with python's *open* (or backtracked all the way to python's close), producingtruncated. With the fix mermaid stays open (content can't cross another \\1 run; close must be at line boundary) and only python's pair matches.""" src = ( "Intro\n" + _FENCE + 'mermaid\nA["x"] -->|note (with parens)| B["y"]\n' + _FENCE + 'python\nprint("hi")\n' + _FENCE ) html = _render_md(src) assert 'class="language-mermaid"' not in html, f"mermaid fence misparing reintroduced: {html!r}" assert 'class="language-python"' in html, f"python fence on its own should match: {html!r}" def test_fence_closed_emits_code_block() -> None: """Baseline: a properly closed fence with its close on its own line emits theblock as expected — the anchor doesn't break the normal case.""" src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE + "\nAfter" html = _render_md(src) assert 'class="language-python"' in html assert "import os" in html def test_fence_close_at_end_of_buffer_emits() -> None: """A fence that closes at the very end of the buffer (no trailing newline) still emits — the anchor accepts end-of-string as a valid line boundary, so the rehydration / static-render path where the buffer ends cleanly at ``` still works.""" src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE html = _render_md(src) assert 'class="language-python"' in html assert "import os" in html def test_fence_close_with_trailing_whitespace_emits() -> None: """A close followed only by spaces / tabs before \\n still counts — CommonMark allows trailing whitespace on the close line.""" src = "Intro\n" + _FENCE + "python\nimport os\n" + _FENCE + " \nAfter" html = _render_md(src) assert 'class="language-python"' in html # --------------------------------------------------------------------------- # postRenderHljs — progressive syntax highlighting + source-keyed cache # --------------------------------------------------------------------------- def _build_hljs_container_js(blocks: list[tuple[str, str]]) -> str: """Build a container withblocks. ``blocks`` is a list of ``(language, source)`` tuples — the language becomes the ``language-X`` class, the source becomes textContent.""" arr = "[" + ", ".join(f"[{json.dumps(lang)}, {json.dumps(src)}]" for lang, src in blocks) + "]" return f""" function buildHljsContainer(blocks) {{ const container = document.createElement('div'); for (const [lang, src] of blocks) {{ const pre = document.createElement('pre'); const code = document.createElement('code'); code.classList.add('language-' + lang); code.textContent = src; pre.appendChild(code); container.appendChild(pre); }} return container; }} const blocks = {arr}; const container = buildHljsContainer(blocks); """ def test_hljs_cache_hit_skips_highlight_call() -> None: """Two postRenderHljs calls on identical source must invoke hljs.highlightElement exactly once — the second call hits the cache and applies the stored markup synchronously. Mirrors the mermaid SVG-cache invariant that lets streamingRender fire on every rAF tick without re-tokenizing every code block.""" scenario = ( _build_hljs_container_js([("python", "import os")]) + """ postRenderHljs(container); const container2 = buildHljsContainer(blocks); postRenderHljs(container2); process.stdout.write(JSON.stringify({ highlightCalls: hljsHighlightCallCount, cacheSize: _hljsCache.size, firstHtml: container.children[0].children[0]._innerHTML, secondHtml: container2.children[0].children[0]._innerHTML, secondHasHljsClass: container2.children[0].children[0]._classes.has('hljs'), })); """ ) out = _run_mermaid_scenario(scenario) assert out["highlightCalls"] == 1, ( "second postRenderHljs call invoked highlightElement — cache miss" ) assert out["cacheSize"] == 1 assert out["firstHtml"] == out["secondHtml"] assert out["secondHasHljsClass"] is True def test_hljs_distinct_sources_highlight_independently() -> None: """Distinct sources each trigger one highlight and cache one entry. Cache key includes the source string, not e.g. just the language.""" scenario = ( _build_hljs_container_js([("python", "import os"), ("python", "print('hi')")]) + """ postRenderHljs(container); process.stdout.write(JSON.stringify({ highlightCalls: hljsHighlightCallCount, cacheSize: _hljsCache.size, })); """ ) out = _run_mermaid_scenario(scenario) assert out["highlightCalls"] == 2 assert out["cacheSize"] == 2 def test_hljs_cache_separates_by_language() -> None: """Same source text under different language fences must NOT collide in the cache — language is part of the key. Otherwise a `python` block of `foo` and a `ruby` block of `foo` would share a single (wrongly-highlighted) cache entry.""" scenario = ( _build_hljs_container_js([("python", "foo"), ("ruby", "foo")]) + """ postRenderHljs(container); process.stdout.write(JSON.stringify({ highlightCalls: hljsHighlightCallCount, cacheSize: _hljsCache.size, })); """ ) out = _run_mermaid_scenario(scenario) assert out["highlightCalls"] == 2 assert out["cacheSize"] == 2 def test_hljs_skips_no_highlight_langs() -> None: """language-mermaid / language-text / language-plaintext etc. must get the `nohighlight` class without invoking hljs.highlightElement. Highlighting plaintext or mermaid source would be both wasteful and ugly.""" scenario = ( _build_hljs_container_js( [("mermaid", "graph TD\\nA-->B"), ("text", "plain"), ("plaintext", "p")] ) + """ postRenderHljs(container); process.stdout.write(JSON.stringify({ highlightCalls: hljsHighlightCallCount, cacheSize: _hljsCache.size, mermaidNoHighlight: container.children[0].children[0]._classes.has('nohighlight'), textNoHighlight: container.children[1].children[0]._classes.has('nohighlight'), plaintextNoHighlight: container.children[2].children[0]._classes.has('nohighlight'), })); """ ) out = _run_mermaid_scenario(scenario) assert out["highlightCalls"] == 0 assert out["cacheSize"] == 0 assert out["mermaidNoHighlight"] is True assert out["textNoHighlight"] is True assert out["plaintextNoHighlight"] is True def test_hljs_terminal_lang_marks_pre_for_terminal_styling() -> None: """Shell-family languages (bash / sh / zsh / console / terminal) must add the `code-terminal` class to the parent, so the stylesheet can give them the terminal look-and-feel.""" scenario = ( _build_hljs_container_js([("bash", "echo hi")]) + """ postRenderHljs(container); process.stdout.write(JSON.stringify({ highlightCalls: hljsHighlightCallCount, preHasTerminalClass: container.children[0]._classes.has('code-terminal'), })); """ ) out = _run_mermaid_scenario(scenario) assert out["highlightCalls"] == 1 assert out["preHasTerminalClass"] is True def test_hljs_cache_evicts_oldest_at_cap() -> None: """FIFO eviction at _HLJS_CACHE_MAX. Mirrors the mermaid cache — prevents unbounded growth on long sessions with many distinct code blocks.""" scenario = """ const cap = _HLJS_CACHE_MAX; for (let i = 0; i < cap + 5; i++) { _cacheFifoEntry(_hljsCache, 'key-' + i, 'val-' + i, cap); } process.stdout.write(JSON.stringify({ size: _hljsCache.size, hasOldest: _hljsCache.has('key-0'), hasNewest: _hljsCache.has('key-' + (cap + 4)), })); """ out = _run_mermaid_scenario(scenario) assert out["size"] == 64 assert out["hasOldest"] is False assert out["hasNewest"] is True def test_hljs_overwrite_does_not_evict() -> None: """Overwriting an existing key is an in-place update, not a new insertion — must not evict the oldest unrelated entry. Same invariant as the mermaid cache.""" scenario = """ const cap = _HLJS_CACHE_MAX; for (let i = 0; i < cap; i++) { _cacheFifoEntry(_hljsCache, 'key-' + i, 'val-' + i, cap); } _cacheFifoEntry(_hljsCache, 'key-5', 'val-updated', cap); process.stdout.write(JSON.stringify({ size: _hljsCache.size, hasOldest: _hljsCache.has('key-0'), updated: _hljsCache.get('key-5'), })); """ out = _run_mermaid_scenario(scenario) assert out["size"] == 64 assert out["hasOldest"] is True, "overwrite evicted oldest unnecessarily" assert out["updated"] == "val-updated" def test_post_render_markdown_invokes_hljs() -> None: """postRenderMarkdown is the public end-of-stream entry point and must still run syntax highlighting after the postRenderHljs refactor — regression guard for the public API surface that app.js / coordinator code already call.""" scenario = ( _build_hljs_container_js([("python", "import os")]) + """ postRenderMarkdown(container); process.stdout.write(JSON.stringify({ highlightCalls: hljsHighlightCallCount, hasHljsClass: container.children[0].children[0]._classes.has('hljs'), })); """ ) out = _run_mermaid_scenario(scenario) assert out["highlightCalls"] == 1 assert out["hasHljsClass"] is True def test_streaming_render_invokes_hljs() -> None: """_streamingRenderApply must call postRenderHljs so closed code fences appear progressively (syntax-highlighted) during streaming, not only at stream_end via streamingRenderFinalize. The cache keeps the per-tick cost down to a synchronous lookup.""" body = _RENDERER_JS.read_text(encoding="utf-8") start = body.index("function _streamingRenderApply") hljs_call = body.find("postRenderHljs(el)", start, start + 4000) assert hljs_call != -1, ( "_streamingRenderApply must call postRenderHljs for progressive " "syntax highlighting during streaming" ) # --------------------------------------------------------------------------- # Attribute-context interpolation lint + pin tests # --------------------------------------------------------------------------- # The JS source uses `'...attr="' + var + '"...'` — so the literal text # between `=` and `+` is `"` (the HTML-attribute opener inside the # JS string) followed by `'` (the JS-string closer). Match that pair, # then optional whitespace + `+` + whitespace + an identifier. _RENDERER_ATTR_INTERP_RE = re.compile( r"=[\"'][\"']\s*\+\s*(?!escapeHtml\b)([a-zA-Z_][a-zA-Z0-9_]*)" ) # Identifiers exempted from the lint. Each entry is reviewer-approved # as known-safe; adding a new one requires a comment explaining why. _RENDERER_KNOWN_SAFE_IDENTIFIERS = { # CALLOUT_TYPES enum lookup ({label, icon} of fixed strings — Note, # Tip, Important, Warning, Caution). `alertType` matched by regex # /(NOTE|TIP|IMPORTANT|WARNING|CAUTION)/, so .toLowerCase() output # is also a fixed set; flows through `info`. "info", } def test_renderer_attribute_context_interpolation_is_safe() -> None: """Pin: every `attr="' + var` string-concat interpolation in renderer.js must use one of: * `escapeHtml(...)` at the call site (allowed by the negative lookahead in the regex), * an identifier matching `safe[A-Z]…` (camelCase convention: the value is pre-escaped at assignment), or * an identifier in :data:`_RENDERER_KNOWN_SAFE_IDENTIFIERS` (reviewer-approved enum lookups / counters). Defence-in-depth lint per issue #553. The current call sites are already safe today via ``inlineMarkdown``'s leading ``escapeHtml`` pass, but that invariant is non-local — a refactor moving image or link rendering out of ``inlineMarkdown`` would silently regress it. The lint locks in the local-escape posture so the safety property is structural rather than emergent. """ body = _RENDERER_JS.read_text(encoding="utf-8") lines = body.splitlines() offenders: list[tuple[int, str, str]] = [] for m in _RENDERER_ATTR_INTERP_RE.finditer(body): ident = m.group(1) if len(ident) > 4 and ident.startswith("safe") and ident[4].isupper(): continue if ident in _RENDERER_KNOWN_SAFE_IDENTIFIERS: continue line_no = body.count("\n", 0, m.start()) + 1 offenders.append((line_no, ident, lines[line_no - 1].rstrip())) assert not offenders, ( f"Found {len(offenders)} unsafe attribute-context " f"interpolation(s) in renderer.js:\n" + "\n".join( f" line {n}: {ident!r} in {line.strip()[:100]}" for n, ident, line in offenders[:10] ) + "\nEither wrap with escapeHtml() at the call site, rename " "the variable to safeXxx (after verifying it is pre-escaped " "at assignment), or add the identifier to " "_RENDERER_KNOWN_SAFE_IDENTIFIERS with a comment explaining " "why it is known-safe (e.g. enum lookup, integer counter)." ) _HANDLER_ATTRS = frozenset( { "onerror", "onload", "onmouseover", "onclick", "onmouseout", "onfocus", "onblur", "onchange", "onsubmit", "onkeydown", "onkeyup", "onkeypress", } ) def _parse_renderer_html(html: str) -> tuple[list[str], list[tuple[str, str]]]: """Parse ``html`` and return ``(start_tags, (tag, attr_name) pairs)``. Two return values because: * ``start_tags`` records every start tag regardless of whether it carries attributes, so a bare ``](https://x/y)") tags, _attrs = _parse_renderer_html(out) assert "script" not in tags, "Link label leaked a real