hermes-agent/apps/desktop/src/lib/markdown-blocks.test.ts
Brooklyn Nicholson d8b59bd60e test(desktop): raise timeout on markdown-blocks property fuzz
The char-level streaming fuzz runs 12 seeds × 500 growing prefixes
(~6000 full+cached lexes) and first trips the pre-fix boundary at
seed 11 / step 257, so the workload can't shrink without gutting the
guard. The work is bounded but exceeds Vitest's 5s per-test default on
CI workers under parallelism, so give this one test an explicit 30s
timeout instead of weakening coverage.
2026-07-18 18:53:41 -04:00

164 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
import { describe, expect, it } from 'vitest'
import { parseMarkdownIntoBlocksCached } from './markdown-blocks'
// The contract: streaming through the cached splitter (one call per growing
// prefix, exactly how Streamdown calls it per flush) must produce, at every
// step, the same blocks as a fresh full lex of that prefix. Byte equality —
// a divergence would change what the memoized block renderer paints.
const CORPUS = `# Heading
Intro paragraph with **bold**, [a link](https://example.com), \`inline\` and $x^2$ math.
- list item one
- list item two
- nested item
1. ordered a
2. loose ordered b
\`\`\`python
def f(x):
return x * 2 # comment with \`\`\` inside string? no — fence chars below
\`\`\`
A paragraph that will be followed by a setext underline
===
| col a | col b |
|---|---|
| 1 | 2 |
| 3 | 4 |
> blockquote line one
> blockquote line two
with a lazy continuation line
<div class="raw">
html block content
</div>
$$
\\int_0^1 x\\,dx = \\tfrac12
$$
Final paragraph after everything, long enough to stream in pieces so the tail
block keeps getting reinterpreted while earlier blocks stay settled.
`
// Deterministic PRNG so failures reproduce.
function mulberry32(seed: number) {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Push the text past the cache MIN_LENGTH thresholds so the incremental
// path actually engages.
const LONG_CORPUS = Array.from({ length: 6 }, () => CORPUS).join('\n')
describe('parseMarkdownIntoBlocksCached', () => {
it('matches a full lex at every random streaming cut (property)', () => {
for (let seed = 1; seed <= 5; seed++) {
const rand = mulberry32(seed)
let cursor = 0
while (cursor < LONG_CORPUS.length) {
cursor = Math.min(LONG_CORPUS.length, cursor + 1 + Math.floor(rand() * 120))
const prefix = LONG_CORPUS.slice(0, cursor)
expect(parseMarkdownIntoBlocksCached(prefix)).toEqual(parseMarkdownIntoBlocks(prefix))
}
}
})
it('matches a full lex when streaming token-by-token through a fence boundary', () => {
const base = `${'settled paragraph one.\n\n'.repeat(100)}opening a fence now:\n`
const tail = '```js\nconst a = 1\nconst b = 2\n```\n\nafter the fence\n'
for (let i = 1; i <= tail.length; i++) {
const text = base + tail.slice(0, i)
expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text))
}
})
it('reconstructs the input exactly (join property the offsets rely on)', () => {
const blocks = parseMarkdownIntoBlocksCached(LONG_CORPUS)
expect(blocks.join('')).toBe(LONG_CORPUS)
})
it('falls back to a full lex for non-append rewrites (edit / branch swap)', () => {
const grown = `${LONG_CORPUS}\n\nappended tail paragraph`
parseMarkdownIntoBlocksCached(grown)
// A REWRITE that shares no prefix lineage must still be correct.
const rewritten = `completely different start\n\n${LONG_CORPUS.slice(500)}`
expect(parseMarkdownIntoBlocksCached(rewritten)).toEqual(parseMarkdownIntoBlocks(rewritten))
})
it('matches a full lex when a trailing setext underline merges the previous block (regression)', () => {
// A trailing `-`/`=` line is a setext underline of the block ABOVE it, so
// appending to it can retroactively merge the previous parse's LAST TWO
// blocks into one. Cached `"…#e\n5\n-"` lexes to [ …, "#e\n", "5\n-" ], but
// grown to `"…#e\n5\n-p2=kj:c"` collapses `#e`/`5\n-` into one block. The
// old boundary dropped only the single last content block, so it reused a
// `"#e\n"` block that no longer exists. `blocks.join('') === text` still
// holds for the wrong split, so the reconstruction guard cannot catch it.
// The settled prefix pushes the text past the append-cache threshold so
// the incremental path actually engages.
const settled = 'settled line paragraph text.\n\n'.repeat(80)
const prev = `${settled}#e\n5\n-`
const grown = `${prev}p2=kj:c`
// Seed the append cache with `prev`, then grow it — the exact two-call
// sequence a streaming flush produces.
parseMarkdownIntoBlocksCached(prev)
expect(parseMarkdownIntoBlocksCached(grown)).toEqual(parseMarkdownIntoBlocks(grown))
})
// 12 seeds × 500 growing prefixes is ~6000 full+cached lexes; it first trips
// the pre-fix boundary at seed 11 / step 257, so the workload can't shrink
// without gutting the guard. The work is bounded but exceeds one test's 5s
// default budget, so raise the timeout rather than weaken the coverage.
it('matches a full lex at every char-level streaming cut over noisy markdown (property fuzz)', () => {
// Character-level append fuzz over the markdown control alphabet — the
// harness that surfaced the setext-underline merge above. Growing a single
// lineage one small chunk at a time keeps `startsWith` lineage intact so
// the incremental path runs on nearly every step; each prefix must
// deep-equal a fresh full lex.
const alphabet = '\n `#*-_>[]()|~:=abcdefghijklmnopqrstuvwxyz0123456789'
for (let seed = 1; seed <= 12; seed++) {
const rand = mulberry32(seed)
// Seed past the append-cache threshold so the incremental path engages.
let text = `seed ${seed}\n\n`.repeat(180)
for (let step = 0; step < 500; step++) {
const n = 1 + Math.floor(rand() * 24)
let chunk = ''
for (let j = 0; j < n; j++) {
chunk += alphabet[Math.floor(rand() * alphabet.length)]
}
text += chunk
expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text))
}
}
}, 30_000)
})