|
| 1 | +// Gap-discovery corpus mined from remend's `__tests__` (Vercel/streamdown). |
| 2 | +// |
| 3 | +// We adopt remend's streaming edge-case INPUTS, not its assertions: remend |
| 4 | +// asserts `remend(input) === healedString` (which closing markers to append to |
| 5 | +// a raw string), a claim about an output type copse does not produce. copse |
| 6 | +// emits HTML/DOM with engineered pending states, so instead of matching a |
| 7 | +// healed string we assert copse's OWN invariants on each input: |
| 8 | +// (a) no raw markdown marker "flashes" as structural markup in any prefix frame, |
| 9 | +// (b) once the input commits, the streamed render equals the static render, |
| 10 | +// (c) every prefix converges to the same fresh full streamed render. |
| 11 | +// See docs/decisions/0001-streaming-markdown-vs-remend-streamdown.md. |
| 12 | +import '../tests/setup-dom-jsdom.ts' |
| 13 | +import { describe, it } from 'node:test' |
| 14 | +import assert from 'node:assert/strict' |
| 15 | +import { renderMarkdown } from './renderer.ts' |
| 16 | +import { sanitizeRenderedMarkdown } from './sanitize.ts' |
| 17 | +import { |
| 18 | + renderStreamingMarkdown, |
| 19 | + splitForStreaming, |
| 20 | + StreamingMarkdownRenderer, |
| 21 | +} from './streaming.ts' |
| 22 | + |
| 23 | +/** Visible streaming HTML: committed blocks + any forming table + live tail. */ |
| 24 | +function extractStreamingDisplay(host: HTMLElement): string { |
| 25 | + const parts: string[] = [] |
| 26 | + const complete = host.querySelector('.stream-complete') |
| 27 | + if (complete) parts.push(complete.innerHTML) |
| 28 | + const forming = host.querySelector('.stream-forming') |
| 29 | + if (forming instanceof HTMLElement && !forming.hidden) parts.push(forming.innerHTML) |
| 30 | + const pending = host.querySelector('.stream-pending') |
| 31 | + if (pending instanceof HTMLElement && !pending.hidden && pending.innerHTML !== '') { |
| 32 | + parts.push(pending.innerHTML) |
| 33 | + } |
| 34 | + return parts.join('') |
| 35 | +} |
| 36 | + |
| 37 | +function streamingDisplayAfterUpdates(markdown: string, cuts: number[]): string { |
| 38 | + const host = document.createElement('div') |
| 39 | + const renderer = new StreamingMarkdownRenderer(host) |
| 40 | + for (const cut of cuts) { |
| 41 | + renderer.update(markdown.slice(0, cut)) |
| 42 | + } |
| 43 | + return extractStreamingDisplay(host) |
| 44 | +} |
| 45 | + |
| 46 | +/** Every prefix length — the corpus inputs are short, so exhaustive is cheap. */ |
| 47 | +function everyPrefix(text: string): number[] { |
| 48 | + return Array.from({ length: text.length + 1 }, (_, i) => i) |
| 49 | +} |
| 50 | + |
| 51 | +/** Parse a streamed HTML frame into a detached element for structural queries. */ |
| 52 | +function frameElement(html: string): HTMLElement { |
| 53 | + const div = document.createElement('div') |
| 54 | + div.innerHTML = html |
| 55 | + return div |
| 56 | +} |
| 57 | + |
| 58 | +interface CorpusCase { |
| 59 | + /** remend `__tests__` file this input is mined from. */ |
| 60 | + readonly source: string |
| 61 | + readonly input: string |
| 62 | + /** |
| 63 | + * Structural markup that must never appear in any prefix frame — the marker |
| 64 | + * would be a mid-stream "flash" copse deliberately holds back. |
| 65 | + */ |
| 66 | + readonly forbiddenSelectors: string |
| 67 | + /** Substrings that must never appear in any prefix frame (e.g. a partial URL). */ |
| 68 | + readonly forbiddenText?: readonly RegExp[] |
| 69 | + /** |
| 70 | + * When true, the marker stays literal text end to end: every prefix frame's |
| 71 | + * visible text is a prefix of the raw input (a half-open trailing marker may |
| 72 | + * be *held*, never turned into markup or extra characters), and the full |
| 73 | + * input reveals the marker verbatim. |
| 74 | + */ |
| 75 | + readonly literalText?: boolean |
| 76 | +} |
| 77 | + |
| 78 | +const CORPUS: readonly CorpusCase[] = [ |
| 79 | + { |
| 80 | + source: 'single-tilde.test.ts', |
| 81 | + input: '20~25', |
| 82 | + // A lone `~` must never open a strikethrough while streaming. |
| 83 | + forbiddenSelectors: 'del, s, strike', |
| 84 | + literalText: true, |
| 85 | + }, |
| 86 | + { |
| 87 | + source: 'comparison-operators.test.ts', |
| 88 | + input: '20 < 30', |
| 89 | + // `<` is escaped, never a spurious tag or entity-driven element mid-stream. |
| 90 | + forbiddenSelectors: 'del, s, em, strong, a, img', |
| 91 | + literalText: true, |
| 92 | + }, |
| 93 | + { |
| 94 | + source: 'underscore-bug', |
| 95 | + input: 'foo_bar_baz', |
| 96 | + // Intra-word underscores must not italicise mid-stream. |
| 97 | + forbiddenSelectors: 'em, strong, i, b', |
| 98 | + literalText: true, |
| 99 | + }, |
| 100 | + { |
| 101 | + source: 'images.test.ts', |
| 102 | + input: '', |
| 109 | + input: '[alt](partial', |
| 110 | + // A forming link reveals label only — no <a>, no partial destination. |
| 111 | + forbiddenSelectors: 'a', |
| 112 | + forbiddenText: [/partial/], |
| 113 | + }, |
| 114 | + { |
| 115 | + source: 'incomplete-link.test.ts (bare label)', |
| 116 | + input: '[documentation', |
| 117 | + // Label-only reveal: no placeholder href (copse's divergence from remend). |
| 118 | + forbiddenSelectors: 'a', |
| 119 | + forbiddenText: [/\[documentation/], |
| 120 | + }, |
| 121 | + { |
| 122 | + source: 'incomplete-link.test.ts (opened destination)', |
| 123 | + input: '[Click here](http://exam', |
| 124 | + // No clickable partial href, no partial URL text, until the URL closes. |
| 125 | + forbiddenSelectors: 'a', |
| 126 | + forbiddenText: [/http:\/\/exam/], |
| 127 | + }, |
| 128 | +] |
| 129 | + |
| 130 | +describe('remend corpus: no marker flash across prefix frames (invariant a)', () => { |
| 131 | + for (const testCase of CORPUS) { |
| 132 | + it(`${testCase.source}: ${JSON.stringify(testCase.input)}`, () => { |
| 133 | + for (const cut of everyPrefix(testCase.input)) { |
| 134 | + const prefix = testCase.input.slice(0, cut) |
| 135 | + const frame = renderStreamingMarkdown(prefix) |
| 136 | + const el = frameElement(frame) |
| 137 | + assert.equal( |
| 138 | + el.querySelectorAll(testCase.forbiddenSelectors).length, |
| 139 | + 0, |
| 140 | + `unexpected ${testCase.forbiddenSelectors} at prefix ${JSON.stringify(prefix)}`, |
| 141 | + ) |
| 142 | + for (const pattern of testCase.forbiddenText ?? []) { |
| 143 | + assert.doesNotMatch( |
| 144 | + frame, |
| 145 | + pattern, |
| 146 | + `unexpected ${String(pattern)} at prefix ${JSON.stringify(prefix)}`, |
| 147 | + ) |
| 148 | + } |
| 149 | + if (testCase.literalText) { |
| 150 | + const visible = el.textContent ?? '' |
| 151 | + // Visible text is always a prefix of the raw input: a half-open |
| 152 | + // trailing marker may be held back, but nothing is injected and no |
| 153 | + // marker becomes markup. |
| 154 | + assert.ok( |
| 155 | + testCase.input.startsWith(visible), |
| 156 | + `visible text ${JSON.stringify(visible)} is not a prefix of the input at ${JSON.stringify(prefix)}`, |
| 157 | + ) |
| 158 | + // At the full input the marker is revealed verbatim as literal text. |
| 159 | + if (cut === testCase.input.length) { |
| 160 | + assert.equal(visible, testCase.input, 'full input did not reveal the marker literally') |
| 161 | + } |
| 162 | + } |
| 163 | + } |
| 164 | + }) |
| 165 | + } |
| 166 | +}) |
| 167 | + |
| 168 | +describe('remend corpus: every prefix converges to the fresh full render (invariant c)', () => { |
| 169 | + for (const testCase of CORPUS) { |
| 170 | + it(`${testCase.source}: ${JSON.stringify(testCase.input)}`, () => { |
| 171 | + const markdown = testCase.input |
| 172 | + const fresh = streamingDisplayAfterUpdates(markdown, [markdown.length]) |
| 173 | + for (const cut of everyPrefix(markdown)) { |
| 174 | + const viaHistory = streamingDisplayAfterUpdates(markdown, [cut, markdown.length]) |
| 175 | + assert.equal(viaHistory, fresh, `cut=${String(cut)}`) |
| 176 | + } |
| 177 | + }) |
| 178 | + } |
| 179 | +}) |
| 180 | + |
| 181 | +describe('remend corpus: committed render equals the static render (invariant b)', () => { |
| 182 | + for (const testCase of CORPUS) { |
| 183 | + it(`${testCase.source}: ${JSON.stringify(testCase.input)}`, () => { |
| 184 | + // A trailing blank line commits the forming line so nothing stays pending. |
| 185 | + const committed = `${testCase.input}\n\n` |
| 186 | + assert.equal( |
| 187 | + splitForStreaming(committed).pending, |
| 188 | + '', |
| 189 | + 'expected the input to fully commit after a blank line', |
| 190 | + ) |
| 191 | + const streamed = streamingDisplayAfterUpdates(committed, [committed.length]) |
| 192 | + const atRest = sanitizeRenderedMarkdown(renderMarkdown(committed)) |
| 193 | + assert.equal(streamed, atRest) |
| 194 | + }) |
| 195 | + } |
| 196 | +}) |
0 commit comments