/**
* 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) => ,
}))
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: `


`,
'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 —
// 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\nBuy cheap pills\n\nbody',
// Round-3 SECURITY/CORRECTNESS: RAWTEXT tokenization runs BEFORE the
// sanitizer, so an UNCLOSED `` 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 ` 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
element explained.\n\n[a]: /x "
"\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-link-definition-destination-does-not-unescape':
'The
element explained.\n\n[a]:
\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
element explained.\n\n- ```html\n
\n',
'rawtext-closer-in-narrow-ordered-marker-line-fence-does-not-unescape':
'The
element explained.\n\n1. ```html\n
\n',
'rawtext-iframe-closer-in-narrow-marker-line-fence-does-not-unescape':
'The element explained.\n\n- ```html\n \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
element explained.\n\n- - ```html\n
\n',
'rawtext-closer-in-nested-marker-line-link-definition-does-not-unescape':
'The
element explained.\n\n- - [a]: /x "
"\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
element explained.\n\n[a](/x "
")\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-inline-link-single-quoted-title-does-not-unescape':
"The
element explained.\n\n[a](/x '
')\n\n## After heading\n\nsecret tail\n",
'rawtext-closer-in-inline-link-paren-title-does-not-unescape':
'The
element explained.\n\n[a](/x (
))\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-inline-image-title-does-not-unescape':
'The
element explained.\n\n\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-inline-link-angle-destination-does-not-unescape':
'The
element explained.\n\n[a](
)\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-quoted-inline-link-title-does-not-unescape':
'The
element explained.\n\n> [a](/x "
")\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-listed-inline-link-title-does-not-unescape':
'The
element explained.\n\n- [a](/x "
")\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-mid-paragraph-inline-link-title-does-not-unescape':
'The
element explained.\n\nSee [a](/x "
") for more.\n\n## After heading\n\nsecret tail\n',
'rawtext-iframe-closer-in-inline-link-title-does-not-unescape':
'The element explained.\n\n[a](/x "")\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
element explained.\n\n[a]:\n/x "
"\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-multiline-link-definition-destination-does-not-unescape':
'The
element explained.\n\n[a]:\n
\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 `
` swallowing the rest of the paragraph).
'rawtext-closer-in-inline-image-alt-does-not-unescape':
'The
element explained.\n\n\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-reference-image-alt-does-not-unescape':
'The
element explained.\n\n![
][r]\n\n[r]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-reference-label-does-not-unescape':
'The
element explained.\n\n[a][
]\n\n[
]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-collapsed-reference-label-does-not-unescape':
'The
element explained.\n\n[
][]\n\n[]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-footnote-label-does-not-unescape':
'The
element explained.\n\nSee[^
]\n\n[^]: note\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-quoted-image-alt-does-not-unescape':
'The
element explained.\n\n> \n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-listed-image-alt-does-not-unescape':
'The
element explained.\n\n- \n\n## After heading\n\nsecret tail\n',
'rawtext-iframe-closer-in-inline-image-alt-does-not-unescape':
'The element explained.\n\n\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
element explained.\n\n[a]: /x "a\\"
"\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-escaped-apostrophe-definition-title-does-not-unescape':
"The
element explained.\n\n[a]: /x 'it\\'s
'\n\n## After heading\n\nsecret tail\n",
'rawtext-closer-in-escaped-paren-definition-title-does-not-unescape':
'The
element explained.\n\n[a]: /x (a\\)
)\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-escaped-bracket-definition-label-does-not-unescape':
'The
element explained.\n\n[a\\]b]: /x "
"\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-escaped-bracket-definition-destination-does-not-unescape':
'The
element explained.\n\n[a\\]b]:
\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
element explained.\n\n[foo\n
]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-multiline-definition-label-first-line-does-not-unescape':
'The
element explained.\n\n[
\nfoo]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-three-line-definition-label-does-not-unescape':
'The
element explained.\n\n[foo\n
\nbar]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-quoted-multiline-definition-label-does-not-unescape':
'The
element explained.\n\n> [foo\n>
]: /x\n\n## After heading\n\nsecret tail\n',
'rawtext-closer-in-multiline-definition-title-does-not-unescape':
'The
element explained.\n\n[a]: /x "line1\n
"\n\n## After heading\n\nsecret tail\n',
'rawtext-iframe-closer-in-multiline-definition-label-does-not-unescape':
'The element explained.\n\n[foo\n]: /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
in prose.\n\n## After heading\n\n\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
in prose.\n\n## After heading\n\n```js\nx\n```html\n
\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 `
`. parse5 then
// swallowed `hello`, `~~~` and `after`. The carve is now the INTERSECTION of
// carve and mask, so over-detection by EITHER engine can only cause escaping.
'mismatched-fence-carve-does-not-shelter-opener':
'```\n```js\n~~~\n```\n
\nhello\n~~~\nafter\n',
// Round-7 REGRESSION: `blankComments` scanned the UNMASKED copy, so a `\n\n[^f]: b
\n'],
['inline tag attribute', 'S.\n\n
\n\nvisible text x\n\n[^f]: b
\n'],
['angle autolink', 'S.\n\n
\n\nvisible text \n\n[^f]: b
\n'],
['literal autolink', 'S.\n\n
\n\nvisible text https://e.example/x[^f]y\n\n[^f]: b
\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\nvisible text ![[^f]](/i.png)\n\n[^f]: b \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
// `` (a CommonMark type-6 block that ends at the blank line) rather
// than `
')
})
})
// ---------------------------------------------------------------------------
// 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 `
`, then streams a fenced
// HTML sample. Mid-emission the fence is unclosed, so escaping first left
// the prose opener live and parse5 swallowed the rest of the message.
const md = 'Explaining
in HTML.')
})
it('does NOT escape an unknown tag inside a mid-emission fence', async () => {
// The carve's "unclosed fence body is unprotected" tradeoff used to fire on
// EVERY streaming frame: a `` mid-fence rendered as literal
// `<their>` until the closer arrived, then snapped back.
const md = 'intro\n\n```html\nx\n'
const container = await renderStable()
expect(container.textContent).toContain('x')
expect(container.textContent).not.toContain('<their>')
})
it('token-by-token growth through an open fence never swallows the document', async () => {
const full = 'Explaining
in HTML.\n\n## Heading\n\n```html\n
v
\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 = '