/** * Phase-0 GOLDEN CHARACTERIZATION TESTS for the markdown renderer unification. * * These snapshots capture the CURRENT output of `SimpleMarkdownRenderer` and * `RichMarkdownRenderer` over a fixture corpus BEFORE the unified engine * refactor. After the refactor the same tests must produce identical * snapshots, except for deltas explicitly reviewed in the migration plan * (blockquote bg token, ODS `article` typography preset, sanitizer applied * to Rich, and the code-block font: the old inline "JetBrains Mono", "SF * Mono", Consolas stack is now the ODS `font-mono` class — see the * ODS-TOKENS note in ../markdown/base-components.tsx). * * Leaf embed components (Video, Reddit/Twitter/LinkedIn embeds, * OGLinkPreview, FigmaEmbed, MarkdownImage) and `mermaid` are mocked to * cheap deterministic stubs — the parity target is the RENDERER PIPELINE * (remark/rehype plugins, preprocessing, component map, heading ids, * sanitization), not the leaves. The same mocks apply pre- and * post-refactor, so snapshots stay comparable. */ import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, act } from '@testing-library/react' // --------------------------------------------------------------------------- // Leaf mocks (stable across the refactor) // --------------------------------------------------------------------------- vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), render: vi.fn(async () => ({ svg: '' })), }, })) vi.mock('@/components/features/video', () => ({ Video: ({ kind, url, poster }: any) => (
), })) vi.mock('@/components/embeds/reddit-embed-client', () => ({ RedditEmbedClient: ({ url }: any) =>
, })) vi.mock('@/components/embeds/twitter-embed-client', () => ({ TwitterEmbedClient: ({ url }: any) =>
, })) vi.mock('@/components/embeds/linkedin-embed-client', () => ({ LinkedInEmbedClient: ({ url }: any) =>
, })) vi.mock('@/components/embeds/og-link-preview', () => ({ OGLinkPreview: ({ url }: any) =>
, OGLinkErrorBoundary: ({ children }: any) => <>{children}, })) vi.mock('@/components/embeds/figma-embed', () => ({ FigmaEmbed: ({ url }: any) =>
, })) vi.mock('@/components/embeds/markdown-image', () => ({ MarkdownImage: ({ src, alt }: any) => {alt, })) import { SimpleMarkdownRenderer } from '../markdown' import { RichMarkdownRenderer } from '../markdown' import { remarkCardLinks } from '../../chat/remark-card-links' import { remarkMentionChips } from '../../chat/remark-mention-chips' import { extractSections } from '../../../utils/markdown-section-extractor' import { scanHeadings } from '../../../utils/markdown-heading-id' import { __buildCloserHaystackForTest, __findInlineCodeRangesForTest, escapeUnknownHtmlTags, } from '../markdown/sanitize' import { splitStreamingBlocks } from '../markdown/streaming' import { isBlankLine } from '../../../utils/markdown-fences' import { unified } from 'unified' import remarkParse from 'remark-parse' // --------------------------------------------------------------------------- // Fixture corpus (per migration plan §D1 parity verification) // --------------------------------------------------------------------------- const SHARED_FIXTURES: Record = { 'gfm-table': ` | Col A | Col B | |-------|-------| | a1 | b1 | | a2 | b2 | `, 'task-list': ` - [x] done item - [ ] open item `, 'nested-lists': ` 1. first - nested a - nested b 2. second 1. sub one 2. sub two `, 'loose-ordered-list': ` 1. first paragraph item 2. second paragraph item 3. third paragraph item `, 'fenced-code-js': '```js\nconst x = 1;\nconsole.log(x < 2 && x > 0);\n```', 'fenced-code-unknown-lang': '```qwerty-lang\nsome text < with > angles\n```', 'inline-code': 'Use `` and `npm install` inline.', mermaid: '```mermaid\ngraph TD;\nA-->B;\n```', 'headings-with-emoji-and-dupes': ` # 🚀 Getting Started ## Setup ## Setup ### Deep Dive 🔧 #### H4 level ##### H5 level ###### H6 level `, links: ` [external](https://example.com/page) and [anchor](#setup) and plain text. `, 'reference-style-link': ` See [the docs][ref] for details. [ref]: https://example.com/reference `, images: ` ![alt text](https://example.com/pic.png) ![](https://example.com/no-alt.png) `, 'empty-image': '![empty]()', blockquote: ` > A quoted paragraph > spanning two lines. `, 'blockquote-with-blank-line': ` > first quoted para > > second quoted para `, hr: 'above\n\n---\n\nbelow', 'raw-html-safe': `
More hidden body
Line
break and Ctrl+C. `, 'raw-html-unknown-tag': 'Share settings and the element.', // The pre-pass must not escape tags the sanitizer keeps: `strike` lives in // `defaultSchema.tagNames` but was absent from SAFE_HTML_TAGS, so authored // legacy markup regressed into visible `<strike>` source text. 'legacy-strike-tag': 'A struck phrase and teletype.', // Round-2: `center`/`font`/`big` were in NONE of the three tag lists, so // they escaped to visible source text even though both pre-unification // renderers rendered them. 'legacy-center-tag': '
centered
\n\ncolored and big.', // Round-2 SECURITY: `title` (and `text`/`desc`/`g`/…) are SVG element names // that are ALSO HTML elements. Unconstrained they let any post or chat // message emit a live ``, which React 19 hoists into <head> — // rewriting the browser tab + SEO title. They are now pinned to an `svg` // ancestor, so a bare one is dropped and only its text remains. 'bare-title-tag-is-inert': '# Real Post\n\n<title>Buy cheap pills\n\nbody', // Round-3 SECURITY/CORRECTNESS: RAWTEXT tokenization runs BEFORE the // sanitizer, so an UNCLOSED `` that is only a CODE SAMPLE (fenced or inline) or only // ATTRIBUTE TEXT satisfied "is closed later" and left the prose opener LIVE // — parse5's RAWTEXT span then swallowed the rest of the message anyway. // An LLM answer about HTML forms is exactly a prose mention plus a code // sample, so the fix was one realistic message away from being bypassed. // The closer search now runs over a length-preserving MASKED copy (code // spans + attribute regions blanked), built from the SAME regex that drives // the escaping carve. In all three the opener must escape and the heading // after it must survive as a real

. 'rawtext-closer-in-fence-does-not-unescape': '\n```', 'rawtext-closer-in-inline-code-does-not-unescape': '` inline', 'rawtext-closer-in-attribute-does-not-unescape': '">x

', 'rawtext-title-closer-in-fence-does-not-unescape': '\n\n# Heading\n\n```html\n\n```', // Round-5 SECURITY: the round-4 mask claimed every index matched the // original string, but it started with `text.toLowerCase()` — and // `toLowerCase()` EXPANDS U+0130 (Turkish dotted capital `İ`, ordinary // prose: İstanbul, İzmir) into `i` + U+0307, 1 code unit → 2. Each one // before an opener shifted the haystack later than the offset the caller // computes from the ORIGINAL text, so `hasLaterCloser` started scanning // BEFORE the opener, matched the already-consumed ``, and left // the prose opener LIVE — the RAWTEXT swallow, reopened. At n≤20 the opener // escaped correctly; at n≥25 the heading and list below became the editable // value of a live textarea. The mask now folds ASCII only (tag names are // ASCII by definition); the length invariant below is the real guard. 'rawtext-mask-survives-turkish-dotted-i': `${'İ'.repeat(25)} \n\n` inside the still-open fence satisfied "is closed later" and // the prose opener above stayed live. The engine now completes the tail // first; this AUTHORED (never-closed) shape is covered by the mask's // `blankUnclosedFence` pass, and the streaming shape by the test below. 'rawtext-closer-in-unclosed-fence-does-not-unescape': 'Explaining \n', // Round-6 SECURITY: the mask understood ONLY column-0..3 ```/~~~ fences, // inline code and attribute runs — so a `` in ANY other ordinary // form of code satisfied "is closed later" and the prose opener stayed live. // All five shapes below were reproduced end-to-end as a live editable // textarea containing the rest of the message. The mask's block-level code // regions are now derived from `createFenceTracker` (plus indented, // blockquoted and commented code) instead of from a flat regex. // // (a) a blockquoted fence — an utterly ordinary chat answer. 'rawtext-closer-in-blockquoted-fence-does-not-unescape': 'The \n> ```\n\n## After heading\n', // (b) an indented (4-space / tab) code block. `FENCE_RE` caps fence indent at // 3 spaces by design, so the tracker never sees these lines at all. 'rawtext-closer-in-indented-code-does-not-unescape': 'Explaining \n\ntail\n', 'rawtext-closer-in-tab-indented-code-does-not-unescape': 'Explaining \n\ntail\n', // (c) a fence indented into a list-item content column. 'rawtext-closer-in-list-indented-fence-does-not-unescape': 'Explaining \n ```\n\ntail\n', // (c2) Round-17 SECURITY: the SAME fence, one container deeper — a list item // INSIDE A BLOCKQUOTE. `blankQuotedCode`'s flush ran the fence + indented // passes over the quote-stripped run but NOT `blankListItemCode`, while // `blankListItemCode` DID call `blankQuotedCode`; that asymmetry was the // hole. `FENCE_RE` caps fence indent at 3 ABSOLUTE columns, so at a // quote-relative content column of 4 the nested tracker misses the fence, // and `blankIndentedCode`'s list-aware threshold (`contentCol + 4` = 8) // does not reach it either — the fence was seen by NO pass. Reproduced // live: one editable `\n> ```\n\n## After heading\n\nsecret tail\n', // (c3) Round-18 SECURITY, the EIGHTH instance and the SHALLOWEST yet — ZERO // nesting depth. `blankListItemCode` read the content-column stack BEFORE // pushing the current line's own marker, so the MARKER LINE never entered // a run and a fence opened ON it was invisible to every pass: `FENCE_RE`'s // 3-column absolute cap misses it and `blankIndentedCode`'s // `contentCol + 4` threshold overshoots it. The run then began AFTER the // opener, so the item's CLOSING fence read as an `open` to the nested // tracker, which blanked to EOF while leaving the code BODY live. // `escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL and the // renderer emitted a live editable `\n ```\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-marker-line-ordered-fence-does-not-unescape': 'The \n ```\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-marker-line-tab-fence-does-not-unescape': 'The \n ```\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-quoted-marker-line-fence-does-not-unescape': 'The \n> ```\n\n## After heading\n\nsecret tail\n', 'rawtext-iframe-closer-in-marker-line-fence-does-not-unescape': 'The \n ```\n\n## After heading\n\nsecret tail\n', // CONTROL for the shape above: the SAME fence one line LOWER (a continuation // line) was always masked correctly. It isolates the defect to "a block opened // ON the marker line", and it must keep working after the reorder. 'rawtext-closer-in-continuation-line-fence-does-not-unescape': 'The \n ```\n\n## After heading\n\nsecret tail\n', // Round-18 SECURITY: a CommonMark code span CROSSES LINE BREAKS, and neither // the mask's inline scan nor `PROTECTED_SPAN_RE` did — both were strictly // per-line. So `` `foo\n` `` left its closer visible in the // haystack, `hasLaterCloser` returned true and the prose opener stayed LIVE. // This is the shape that is not a CONTAINER at all, so no container sweep // could reach it; the scan unit is now the paragraph segment. The renderer // emitting `foo ` is the proof the closer is a sample. 'rawtext-closer-in-multiline-code-span-does-not-unescape': 'The ` today.\n\n## After heading\n\nsecret tail\n', 'rawtext-iframe-closer-in-multiline-code-span-does-not-unescape': 'The ` today.\n\n## After heading\n\nsecret tail\n', // Round-18 SECURITY: remark consumes a LINK REFERENCE DEFINITION entirely and // emits no node, so a `` in its destination or title is not a real // closer — but it survived into the haystack and kept the prose opener live // (byte-identical output in both spellings). 'rawtext-closer-in-link-definition-title-does-not-unescape': 'The "\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-link-definition-destination-does-not-unescape': 'The \n\n## After heading\n\nsecret tail\n', // --------------------------------------------------------------------------- // Round-19 SECURITY. Five findings, all authored here rather than left to the // sweep — the sweep is what MISSED them (see `markerLineListWrap`). // --------------------------------------------------------------------------- // (i) The NINTH instance: `blankListItemCode`'s `top >= 4` gate, justified by // a table entry claiming "below column 4 the top-level passes already cover // the line at the right column". True of a CONTINUATION line (absolute // indent 2-3 is inside `FENCE_RE`'s cap) and FALSE of the MARKER line, // which is examined only at column 0 where the leading `- ` / `1. ` is not // whitespace. So at the two MOST COMMON marker spellings the marker line // got no run at all. The fence is EOF-TERMINATED on purpose: the closed // spelling is rescued only INCIDENTALLY by `findInlineCodeRanges` matching // the two backtick runs, so the live hole is the unclosed one — the state // every fence passes through mid-stream. 'rawtext-closer-in-narrow-marker-line-fence-does-not-unescape': 'The \n', 'rawtext-closer-in-narrow-ordered-marker-line-fence-does-not-unescape': 'The \n', 'rawtext-iframe-closer-in-narrow-marker-line-fence-does-not-unescape': 'The \n', // (ii) The TENTH instance: `LIST_MARKER_RE` matches only the FIRST marker on a // line, so an INNER item's content column was never pushed and the fix // above does not reach these. `blankListItemCode` now recurses into // ITSELF on the stripped run. The link-definition spelling also falsifies // the table's "absorbs … ONE list marker" claim. 'rawtext-closer-in-nested-marker-line-fence-does-not-unescape': 'The \n', 'rawtext-closer-in-nested-marker-line-link-definition-does-not-unescape': 'The "\n', // (iii) A whole UNCOVERED SHELTER CLASS: the INLINE link/image destination and // title. remark consumes them into href/title exactly as it consumes a // reference definition's, so the closer is fake — but only the // DEFINITION spelling had a pass. All eight spellings reproduced live. 'rawtext-closer-in-inline-link-title-does-not-unescape': 'The ")\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-inline-link-single-quoted-title-does-not-unescape': "The ')\n\n## After heading\n\nsecret tail\n", 'rawtext-closer-in-inline-link-paren-title-does-not-unescape': 'The ))\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-inline-image-title-does-not-unescape': 'The ")\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-inline-link-angle-destination-does-not-unescape': 'The )\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-quoted-inline-link-title-does-not-unescape': 'The ")\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-listed-inline-link-title-does-not-unescape': 'The ")\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-mid-paragraph-inline-link-title-does-not-unescape': 'The ") for more.\n\n## After heading\n\nsecret tail\n', 'rawtext-iframe-closer-in-inline-link-title-does-not-unescape': 'The ")\n\n## After heading\n\nsecret tail\n', // (iv) `blankLinkDefinitions` missed the MULTI-LINE definition spelling — // CommonMark allows destination and/or title on FOLLOWING lines, and the // continuation state only accepted a BARE QUOTED TITLE. The `[a]:` line // blanked; the `destination + title` line stayed fully visible. 'rawtext-closer-in-multiline-link-definition-does-not-unescape': 'The "\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-multiline-link-definition-destination-does-not-unescape': 'The \n\n## After heading\n\nsecret tail\n', // (v) ROUND 20 — the BRACKET half of the very class (iii) opened. Round 19 // wrote the generalization ("every region CommonMark turns into an // ATTRIBUTE rather than document text is a shelter of the same kind") and // then implemented it for the `(…)` payload only, on a rationale that is // true of an INLINE LINK's `[…]` and false of every other bracket // spelling: an image's alt is a string ATTRIBUTE, and a reference or // footnote label is an IDENTIFIER remark never renders. All seven // reproduced live in BOTH renderers (`escapeUnknownHtmlTags` byte- // identical, a live `](/x)\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-reference-image-alt-does-not-unescape': 'The ][r]\n\n[r]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-reference-label-does-not-unescape': 'The ]\n\n[]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-collapsed-reference-label-does-not-unescape': 'The ][]\n\n[]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-footnote-label-does-not-unescape': 'The ]\n\n[^]: note\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-quoted-image-alt-does-not-unescape': 'The ](/x)\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-listed-image-alt-does-not-unescape': 'The ](/x)\n\n## After heading\n\nsecret tail\n', 'rawtext-iframe-closer-in-inline-image-alt-does-not-unescape': 'The ](/x)\n\n## After heading\n\nsecret tail\n', // (vi) ROUND 21 — the SAME pass, the SAME round, the OTHER half. Round 20 // converted this pass's LENGTH caps to blank-through and left its SHAPE // regexes as fail-open declines. Two dimensions were uncovered: // // (a) BACKSLASH-BLINDNESS. `LINK_DEF_TITLE` / `LINK_DEF_DEST` and the // label class `[^\]\n]*` stop at the FIRST delimiter, escaped or // not, while CommonMark permits `\"` in a `"…"` title, `\'` in // `'…'`, `\)` in `(…)` and `\]` in a label. The class stopped early, // the full-line anchor then failed, and the line stayed VISIBLE — // while remark still consumed the definition and emitted NOTHING. // The unescaped CONTROL (`rawtext-closer-in-link-definition-title-…` // above) blanks correctly, which is what makes the ESCAPE the cause. 'rawtext-closer-in-escaped-quote-definition-title-does-not-unescape': 'The "\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-escaped-apostrophe-definition-title-does-not-unescape': "The '\n\n## After heading\n\nsecret tail\n", 'rawtext-closer-in-escaped-paren-definition-title-does-not-unescape': 'The )\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-escaped-bracket-definition-label-does-not-unescape': 'The "\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-escaped-bracket-definition-destination-does-not-unescape': 'The \n\n## After heading\n\nsecret tail\n', // (b) A LABEL or TITLE that opens on one line and closes on a LATER one // was examined by NOBODY. The `'none' | 'needDest' | 'needTitle'` // state modelled continuation only AFTER the `]:`, and // `blankBracketLabels` deliberately excludes a bare `[…]`, so the // multi-line label had no pass at all. 'rawtext-closer-in-multiline-definition-label-does-not-unescape': 'The ]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-multiline-definition-label-first-line-does-not-unescape': 'The \nfoo]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-three-line-definition-label-does-not-unescape': 'The \nbar]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-quoted-multiline-definition-label-does-not-unescape': 'The ]: /x\n\n## After heading\n\nsecret tail\n', 'rawtext-closer-in-multiline-definition-title-does-not-unescape': 'The "\n\n## After heading\n\nsecret tail\n', 'rawtext-iframe-closer-in-multiline-definition-label-does-not-unescape': 'The ]: /x\n\n## After heading\n\nsecret tail\n', // (d) an HTML comment. parse5 consumes it as comment data; it is not a closer. 'rawtext-closer-in-html-comment-does-not-unescape': 'Explaining -->\n\ntail\n', // (e) an INFO-STRING closer. CommonMark forbids an info string on a closing // fence, but `PROTECTED_SPAN_RE`'s closer alternative allowed `[^\n]*` — // so the masked span ended at the ```html line and the real code content // after it went unmasked. `createFenceTracker` gets this right. 'rawtext-closer-after-info-string-closer-does-not-unescape': 'Explaining \n```\n\ntail\n', // Round-7 SECURITY (the class-closing one): the carve (`PROTECTED_SPAN_RE`) // and the mask (tracker-derived) run DIFFERENT engines, so the carve can // protect a region the mask correctly blanked — and a protected span is // pushed through VERBATIM, so a live RAWTEXT opener inside it never reaches // `escapeOutsideFences` at all. Here the regex's closer alternative accepts an // info string, so its first span ends early at the ```js line, it desyncs, and // it opens a NEW span at the `~~~` line (ordinary text to CommonMark) that // runs past remark's real closer and shelters the `\n', 'truncated-comment-in-fence-does-not-blank-to-eof': '```html\n\n\n[^f]: b \n'], ['inline tag attribute', 'S.\n\n\n'], ['angle autolink', 'S.\n\n\n'], ['literal autolink', 'S.\n\n\n'], ])('escapes the opener when the only "reference" is a PHANTOM: %s', async (name, md) => { expect(escapeUnknownHtmlTags(md), name).toContain('<') const container = await renderStable() expect(container.querySelectorAll('textarea').length, name).toBe(0) expect(container.textContent, name).toContain('visible text') }) // …and the same escalation, with an ATTRIBUTE-BEARING opener: the phantom used // to leave a live third-party iframe keeping `src` and `width`. it('escapes an attribute-bearing iframe opener behind a phantom reference', async () => { const md = 'S.\n\n\n' const container = await renderStable() expect(container.querySelectorAll('iframe').length).toBe(0) expect(container.textContent).toContain('visible text') }) // THE OVER-BLANK DIRECTION, which the narrowing must NOT cross: where remark // really does resolve the reference, the definition body is document text and // its closer is REAL, so the element must keep rendering. The opener here is // `\n` const container = await renderStable() expect(container.querySelectorAll('iframe').length, name).toBe(1) }) // THE ONE NAMED RESIDUAL, pinned so it stays visible rather than becoming the // next round's surprise: a reference living ONLY inside another, itself // UNREFERENCED, definition's body. remark drops both, so this is a phantom // too, but seeing it needs a FIXPOINT (blank, rebuild the ref-mask, recount) // rather than one pass. Deliberately not built — see the pass docblock. it('KNOWN RESIDUAL: a reference inside another dead definition still counts', async () => { const md = 'S.\n\n\n' const container = await renderStable() expect(container.querySelectorAll('iframe').length).toBe(1) }) // Round-22: a payload that RUNS OUT OF INPUT is a SHAPE decline, not a cap // exit — the common streaming shape (`see [a](/x` as the last token). It used // to return `limit`, which the caller widened to `paragraphEnd`, blanking the // paragraph tail and escaping an earlier opener mid-stream that unescaped // again on completion (a visible flicker). it('does not blank the paragraph tail for a link payload cut off at end of input', () => { expect(__buildCloserHaystackForTest('see [a](/x')).toBe('see [a](/x') expect(__buildCloserHaystackForTest('see [a](/x "t')).toBe('see [a](/x "t') }) // …while the CAP path must still BLANK THROUGH (the round-20 fix). it('still blanks through the cap for an over-long payload', () => { const md = `[a](/x "<${'y'.repeat(1100)}>")\n\ntail\n` expect(__buildCloserHaystackForTest(md)).not.toContain('') }) }) // --------------------------------------------------------------------------- // Streaming pre-pass ordering (round-5) // --------------------------------------------------------------------------- describe('streaming completes the tail BEFORE escaping', () => { it('a `` inside a still-open fence does not unescape the prose opener', async () => { // The canonical shape: an LLM explains `\n' const container = await renderStable() expect(container.querySelectorAll('textarea').length).toBe(0) expect(container.querySelector('h2')?.textContent).toBe('Heading') expect(container.textContent).toContain('Explaining \n```\n' // Start once `## Heading\n` has fully arrived (earlier cuts legitimately // show a partial title) and walk the rest of the fence in small steps. const from = full.indexOf('## Heading') + '## Heading\n'.length for (let cut = from; cut <= full.length; cut += 3) { const container = await renderStable( , ) expect(container.querySelector('h2')?.textContent, `cut=${cut}`).toBe('Heading') // The h2 assertion alone is NOT enough: a swallow that starts after the // heading leaves the

intact while the rest of the message becomes a // live textarea value. Pin the absence of the swallow itself. const textareas = Array.from(container.querySelectorAll('textarea')) expect(textareas.length, `cut=${cut}`).toBe(0) } }) }) // --------------------------------------------------------------------------- // RichMarkdownRenderer parity // --------------------------------------------------------------------------- describe('RichMarkdownRenderer golden parity', () => { for (const [name, md] of Object.entries({ ...SHARED_FIXTURES, ...RICH_ONLY_FIXTURES })) { it(`renders fixture: ${name}`, async () => { const container = await renderStable() expect(normalize(container.innerHTML)).toMatchSnapshot() }) } it('renders headings with backend sectionIds + demoteMarkdownH1ToH2', async () => { const container = await renderStable( , ) expect(normalize(container.innerHTML)).toMatchSnapshot() }) }) // --------------------------------------------------------------------------- // Heading-id ↔ section-extractor agreement (three slug copies today — // this pins the CURRENT agreement so the Phase-1 shared helper can prove // it changed nothing) // --------------------------------------------------------------------------- describe('heading-id and section-extractor slug agreement', () => { // Includes a THIRD duplicate (`setup-3` — the suffix counter must keep // counting, not reset per pair) and a SYMBOL-ONLY heading (`## !!!`, whose // slug collapses to '' and hits the `section-N` fallback, where the two // implementations previously used different counters). const AGREEMENT_MD = `# 🚀 Getting Started\n\n## Setup\n\n## Setup\n\n## Setup\n\n## !!!\n\n## Weird — punct!, chars?\n` it('renderer-generated heading ids equal extractSections ids for the same markdown', async () => { const sections = extractSections(AGREEMENT_MD, { maxLevel: 2 }) const container = await renderStable() const renderedIds = Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id) expect(renderedIds).toEqual(sections.map((s) => s.id)) expect(sections).toMatchSnapshot('extracted-sections') }) // --- round-15 REGRESSION vs `main`: CRLF documents ------------------------ // `main`'s extractor pattern was `^(#{1,N})\\s+(.+)` — no end anchor at all, // so the trailing `\\r` of a CRLF line landed in the title and was trimmed off // afterwards. The unified `ATX_HEADING_RE` ends `[ \\t]*$`, which `\\r` does not // satisfy, so a CRLF document produced ZERO sections and ZERO heading anchors // where `main` produced them: every CRLF-stored doc / blog / release body // silently lost its TOC, its in-page anchors and its doc-SEO heading links. // (Direct A/B over this fixture: `main` → ["Real","Second"], branch → [].) const CRLF_HEADINGS_MD = '# Real\r\n\r\nbody\r\n\r\n## Second\r\n\r\n```\r\n# In fence\r\n```\r\n' it('CRLF documents scan the same headings as their LF twin', () => { const lf = CRLF_HEADINGS_MD.replace(/\r/g, '') expect(scanHeadings(CRLF_HEADINGS_MD)).toEqual(scanHeadings(lf)) expect(extractSections(CRLF_HEADINGS_MD, { maxLevel: 2 }).map((sec) => sec.title)).toEqual([ 'Real', 'Second', ]) // …and the `# In fence` line is still fence content in BOTH spellings — // the fence tracker used to match no CRLF line at all. expect(extractSections(CRLF_HEADINGS_MD, { maxLevel: 2 })).toEqual( extractSections(lf, { maxLevel: 2 }), ) }) it('CRLF renderer heading ids equal extractSections ids', async () => { const sections = extractSections(CRLF_HEADINGS_MD, { maxLevel: 2 }) const container = await renderStable() const renderedIds = Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id) expect(renderedIds).toEqual(sections.map((sec) => sec.id)) expect(renderedIds).toEqual(['real', 'second']) }) it('heading ids are STABLE across re-renders of the same content', async () => { const idsOf = (c: HTMLElement) => Array.from(c.querySelectorAll('h1, h2')).map((el) => el.id) // Two independent mounts must agree… const first = await renderStable() const firstIds = idsOf(first) const second = await renderStable() expect(idsOf(second)).toEqual(firstIds) // …and so must repeated render passes of the SAME instance. Before the // per-pass counter reset this produced `setup-4`, `setup-5`, … on every // re-render, silently breaking every `#anchor` deep link. let container!: HTMLElement let rerender!: (ui: React.ReactElement) => void await act(async () => { const result = render() container = result.container rerender = result.rerender await new Promise((r) => setTimeout(r, 0)) }) expect(idsOf(container)).toEqual(firstIds) for (let pass = 0; pass < 3; pass++) { await act(async () => { // A fresh `brokenLinks` array each time is exactly what a parent // re-render looks like — it must not renumber anything. rerender() await new Promise((r) => setTimeout(r, 0)) }) expect(idsOf(container), `pass ${pass}`).toEqual(firstIds) } }) // --- round-2: the ids must be PURE --------------------------------------- it('ids are identical under a StrictMode double render', async () => { // Heading renderers are element types, re-rendered INDEPENDENTLY of the // parent that used to `reset()` the counter — so StrictMode's second // invocation suffixed every id with `-2` in dev and broke // extractor↔renderer parity. The pure line-keyed map is immune. const plain = await renderStable() const plainIds = Array.from(plain.querySelectorAll('h1, h2')).map((el) => el.id) const strict = await renderStable( , ) const strictIds = Array.from(strict.querySelectorAll('h1, h2')).map((el) => el.id) expect(strictIds).toEqual(plainIds) }) it('emits NO duplicate ids mid-stream, where completed blocks are memoized', async () => { // A memoized completed block does NOT re-render on the pass that used to // reset the counter, so block 0 kept `id="setup"` while the re-rendered // tail re-derived `setup` from an empty counter — two live `id="setup"` // in the DOM, and `#setup` resolving to the wrong node for the whole // stream. const STREAM_MD = '## Setup\n\nfirst body\n\n## Setup\n\nsecond body\n\ntail text' let container!: HTMLElement let rerender!: (ui: React.ReactElement) => void await act(async () => { const result = render() container = result.container rerender = result.rerender await new Promise((r) => setTimeout(r, 0)) }) const idsOf = () => Array.from(container.querySelectorAll('h1, h2, h3')).map((el) => el.id) expect(idsOf()).toEqual(['setup', 'setup-2']) // Grow the tail token-by-token; the completed blocks stay memoized. for (const suffix of [' and', ' and more', ' and more words']) { await act(async () => { rerender() await new Promise((r) => setTimeout(r, 0)) }) const ids = idsOf() expect(ids, `after "${suffix}"`).toEqual(['setup', 'setup-2']) expect(new Set(ids).size, 'no duplicate DOM ids').toBe(ids.length) } // …and the settled whole-document parse agrees with the extractor. const done = await renderStable() expect(Array.from(done.querySelectorAll('h1, h2')).map((el) => el.id)).toEqual( extractSections(STREAM_MD, { maxLevel: 2 }).map((s) => s.id), ) }) // --- round-3: producer/consumer scanner parity ------------------------- it('agrees with the extractor across fence, container and setext shapes', async () => { // Every line here was a DRIFT between the two implementations: // - a `~~~` fence: the extractor toggled only on '```', so `## Fenced` // became a phantom section with no matching renderer id; // - an indented / longer-run fence: same class; // - `> ## Quoted` and `- ## Listed`: real

s in mdast that the // renderer's column-0..3 scan missed entirely, so BOTH of two // identical ones fell to the suffix-free fallback and emitted // DUPLICATE DOM ids; // - `Setext Title\n---`: a real

the renderer never scanned AND // which flipped the extractor into a phantom "YAML block" that // swallowed every heading after it. const DRIFT_MD = [ '# Top', '', '~~~text', '## Fenced Not A Heading', '~~~', '', ' ```', '## Also Fenced', ' ```', '', 'Setext Title', '---', '', '> ## Quoted', '', '- ## Listed', '', '> ## Quoted', '', '## Tail', '', ].join('\n') const sections = extractSections(DRIFT_MD, { maxLevel: 2 }) const container = await renderStable() const renderedIds = Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id) expect(renderedIds).toEqual(sections.map((s) => s.id)) expect(new Set(renderedIds).size, 'no duplicate DOM ids').toBe(renderedIds.length) expect(renderedIds, 'fenced headings contribute NO id').not.toContain('fenced-not-a-heading') expect(sections).toMatchSnapshot('drift-sections') }) // --- round-4: the setext scan must match mdast EXACTLY ------------------ // The scanner is a line-based approximation of what remark will parse. Where // it diverges, the renderer's line-keyed id lookup misses (dead deep link) // or the extractor publishes a phantom TOC entry. These pin the three // divergences against remark itself rather than against a hand-written // expectation. describe('setext scanning matches mdast', () => { const mdastHeadings = (md: string) => { const tree = unified().use(remarkParse).parse(md) as any const out: Array<{ line: number; level: number }> = [] const walk = (n: any) => { if (n.type === 'heading') out.push({ line: n.position.start.line, level: n.depth }) for (const child of n.children ?? []) walk(child) } walk(tree) return out } const scanned = (md: string) => scanHeadings(md).map((h) => ({ line: h.line, level: h.level })) it('multi-line setext is ONE heading at the run\'s FIRST line', () => { const md = 'Foo\nbar\n===\n' // mdast: {line:1, depth:1, text:"Foo\nbar"}. The scanner used to record // only the LAST paragraph line ({line:2, text:"bar"}), so the renderer's // lookup missed and fell back to a slug of the rendered children // (`foo-bar`) while the extractor published `bar`. expect(scanned(md)).toEqual(mdastHeadings(md)) expect(scanHeadings(md)[0].text).toBe('Foo\nbar') expect(extractSections(md).map((s) => s.id)).toEqual(['foo-bar']) }) it('container-nested setext IS a heading', () => { const md = '> Quote title\n> ---\n' // A real h2 in mdast; the container-prefix guard used to kill the // candidate, so it was invisible to the TOC. expect(scanned(md)).toEqual(mdastHeadings(md)) expect(extractSections(md).map((s) => s.id)).toEqual(['quote-title']) }) it('a `---` inside a raw HTML block is NOT a setext underline', () => { const md = '
\nText\n---\n
\n' // mdast emits nothing (the whole thing is one HTML block); the scanner // used to publish a phantom `Text` h2. expect(mdastHeadings(md)).toEqual([]) expect(scanned(md)).toEqual([]) expect(extractSections(md)).toEqual([]) }) it('an underline with a DIFFERENT container prefix is a thematic break', () => { const md = '> Quote\n\n---\n' expect(scanned(md)).toEqual(mdastHeadings(md)) expect(scanned(md)).toEqual([]) }) it('renderer ids agree with the extractor for multi-line setext', async () => { const md = 'Foo\nbar\n===\n\nbody\n' const container = await renderStable() expect(Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id)).toEqual( extractSections(md, { maxLevel: 2 }).map((s) => s.id), ) }) }) it('honors an AUTHORED anchor on a raw-HTML heading', async () => { // The sanitize schema allows `id` and disables clobbering precisely so // hand-picked anchors survive; the renderer then overwrote them with the // slug of the heading text, silently breaking every deep link to them. const md = '

Pricing

\n\nbody' const container = await renderStable() expect(container.querySelector('h2')?.id).toBe('pricing-faq') }) it('strips inline markdown from heading text before slugifying (extractor parity)', async () => { const md = '## **Bold** Setup\n\n## `code` Heading\n' const container = await renderStable() expect(Array.from(container.querySelectorAll('h2')).map((el) => el.id)).toEqual( extractSections(md, { maxLevel: 2 }).map((s) => s.id), ) }) })