/** @jest-environment @happy-dom/jest-environment */ /// import createStreamingRenderer, { STREAMING_ERROR } from './createStreamingRenderer'; const OPTIONS: Parameters[0] = { markdownRespectCRLF: false }; const INIT: Parameters[1] = { externalLinkAlt: 'Opens in a new window' }; let currentContainer: HTMLElement | null = null; function setup() { const container = document.createElement('div'); document.body.appendChild(container); const renderer = createStreamingRenderer(OPTIONS, INIT); currentContainer = container; const nextOptions = () => ({ container }); return { container, nextOptions, renderer }; } function getWrapperHTML(container: HTMLElement): string { const wrapper = container.firstElementChild; return wrapper ? wrapper.innerHTML : ''; } // Returns the comment node that separates committed from active content. function getSentinel(container: HTMLElement): Comment | null { const wrapper = container.firstElementChild; if (!wrapper) { return null; } for (const child of Array.from(wrapper.childNodes)) { if (child.nodeType === Node.COMMENT_NODE) { return child as Comment; } } return null; } // Returns [committedHTML, activeHTML] split by the sentinel comment. // Uses Range to extract each half into a fragment, then serializes via innerHTML. function splitBySentinel(container: HTMLElement): [string, string] | null { const sentinel = getSentinel(container); if (!sentinel) { return null; } const wrapper = container.firstElementChild!; const committedRange = document.createRange(); committedRange.setStartBefore(wrapper.firstChild!); committedRange.setEndBefore(sentinel); const activeRange = document.createRange(); activeRange.setStartAfter(sentinel); activeRange.setEndAfter(wrapper.lastChild!); const committedDiv = document.createElement('div'); const activeDiv = document.createElement('div'); committedDiv.appendChild(committedRange.cloneContents()); activeDiv.appendChild(activeRange.cloneContents()); return [committedDiv.innerHTML.trim(), activeDiv.innerHTML.trim()]; } describe('createStreamingRenderer', () => { afterEach(() => { if (!currentContainer) { return; } const wrapper = currentContainer.firstElementChild as HTMLElement | null; // eslint-disable-next-line security/detect-object-injection if (wrapper[STREAMING_ERROR]) { // eslint-disable-next-line security/detect-object-injection console.warn('Streaming renderer error gonna fail the test:', wrapper[STREAMING_ERROR]); } expect(wrapper?.dataset.renderError).toBeUndefined(); expect(wrapper?.dataset.renderErrorCount).toBeUndefined(); currentContainer = null; }); describe('single block', () => { test('should render a paragraph without sentinel', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Hello, World!', nextOptions()); expect(getWrapperHTML(container)).toBe('

Hello, World!

'); expect(getSentinel(container)).toBeNull(); }); test('should render inline formatting within a single block', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Hello **bo', nextOptions()); renderer.next('ld** and *ita', nextOptions()); renderer.next('lic*', nextOptions()); expect(getWrapperHTML(container)).toBe('

Hello bold and italic

'); expect(getSentinel(container)).toBeNull(); }); }); describe('multi-block split', () => { test('should split two paragraphs into committed and active', () => { const { container, nextOptions, renderer } = setup(); renderer.next('First paragraph\n\nSecond paragraph', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toBe('

First paragraph

'); expect(split![1]).toBe('

Second paragraph

'); }); test('should extract link definitions during full reparse path', () => { const { container, nextOptions, renderer } = setup(); renderer.next('See [link][1]\n\n[1]: https://example.com "Example"', nextOptions()); expect(getSentinel(container)).not.toBeNull(); expect(container.querySelector('a[href="https://example.com"]')?.textContent).toBe('link'); const { definitions } = renderer.finalize(nextOptions()); expect(definitions).toEqual([ expect.objectContaining({ identifier: '1', url: 'https://example.com', title: 'Example' }) ]); }); test('should preserve newline between paragraphs in textContent', () => { const { container, nextOptions, renderer } = setup(); renderer.next('First\n\nSecond', nextOptions()); expect(container.textContent).toContain('First'); expect(container.textContent).toContain('Second'); expect(container.textContent).toMatch(/First\s+Second/u); }); test('should split three paragraphs with two committed and one active', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Block A\n\nBlock B\n\nBlock C', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toContain('Block A'); expect(split![0]).toContain('Block B'); expect(split![1]).toBe('

Block C

'); }); test('should preserve newline in textContent for code span followed by paragraph', () => { const { container, nextOptions, renderer } = setup(); renderer.next('`t=undefined`\n\nA quick', nextOptions()); // The \n between the two

blocks must survive the committed/active split. expect(container.textContent).toMatch(/t=undefined\nA quick/u); }); }); describe('htmlFlow blocks', () => { test('should keep multi-element htmlFlow block whole in committed split', () => { const { container, nextOptions, renderer } = setup(); // htmlFlow that produces two sibling elements, followed by a paragraph. renderer.next('\n\n\nTrailing paragraph', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); // Both must be in committed; the paragraph is the active block. expect(split![0]).toContain(''); expect(split![0]).toContain(''); expect(split![1]).toBe('

Trailing paragraph

'); }); test('should render htmlFlow as single block without sentinel', () => { const { container, nextOptions, renderer } = setup(); renderer.next('
Hello
', nextOptions()); expect(getWrapperHTML(container)).toBe('
Hello
'); expect(getSentinel(container)).toBeNull(); }); }); describe('incremental streaming (append-only chunks)', () => { test('should accumulate single block content without sentinel', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Hello', nextOptions()); expect(getWrapperHTML(container)).toBe('

Hello

'); expect(getSentinel(container)).toBeNull(); renderer.next(' World', nextOptions()); expect(getWrapperHTML(container)).toBe('

Hello World

'); expect(getSentinel(container)).toBeNull(); }); test('should promote first block to committed when new block starts', () => { const { container, nextOptions, renderer } = setup(); renderer.next('First block', nextOptions()); expect(getSentinel(container)).toBeNull(); renderer.next('\n\nSecond block', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toBe('

First block

'); expect(split![1]).toBe('

Second block

'); }); test('should grow active block via incremental path', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Block 1\n\nPartial', nextOptions()); const split1 = splitBySentinel(container); expect(split1).not.toBeNull(); expect(split1![1]).toBe('

Partial

'); renderer.next(' more text', nextOptions()); const split2 = splitBySentinel(container); expect(split2).not.toBeNull(); expect(split2![0]).toBe('

Block 1

'); expect(split2![1]).toBe('

Partial more text

'); }); test('should resolve active references using committed definitions during active growth', () => { const { container, nextOptions, renderer } = setup(); renderer.next('First paragraph\n\n[1]: https://example.com "Example"\n\nSecond [link', nextOptions()); expect(getSentinel(container)).not.toBeNull(); expect(container.querySelector('a[href="https://example.com"]')).toBeNull(); renderer.next('][1]', nextOptions()); expect(getSentinel(container)).not.toBeNull(); expect(container.querySelector('a[href="https://example.com"]')?.textContent).toBe('link'); }); test('should commit additional blocks during incremental streaming', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Block A\n\nBlock B', nextOptions()); const split1 = splitBySentinel(container); expect(split1![0]).toBe('

Block A

'); expect(split1![1]).toBe('

Block B

'); // Append a new block boundary — Block B becomes committed, Block C is active. renderer.next('\n\nBlock C', nextOptions()); const split2 = splitBySentinel(container); expect(split2).not.toBeNull(); expect(split2![0]).toContain('Block A'); expect(split2![0]).toContain('Block B'); expect(split2![1]).toBe('

Block C

'); }); test('should keep incremental split when committed block references a later definition', () => { const { container, nextOptions, renderer } = setup(); renderer.next('First paragraph [first][1]\n\nSecond paragraph [first][1]', nextOptions()); expect(getSentinel(container)).not.toBeNull(); expect(container.querySelector('a[href="https://example.com"]')).toBeNull(); renderer.next('\n\n[1]: https://example.com "Example"', nextOptions()); expect(getSentinel(container)).not.toBeNull(); expect(container.querySelectorAll('a[href="https://example.com"]').length).toBe(1); expect(container.querySelector('a[href="https://example.com"]')?.textContent).toBe('first'); }); test('should extract link definitions when active tail grows', () => { const { container, nextOptions, renderer } = setup(); renderer.next('See [link][1]\n\n', nextOptions()); renderer.next('[1]: https://example.com "Example"', nextOptions()); expect(getSentinel(container)).not.toBeNull(); expect(container.querySelector('a[href="https://example.com"]')?.textContent).toBe('link'); const { definitions } = renderer.finalize(nextOptions()); expect(definitions).toEqual([ expect.objectContaining({ identifier: '1', url: 'https://example.com', title: 'Example' }) ]); }); test('should extract link definitions when definition block becomes committed', () => { const { container, nextOptions, renderer } = setup(); renderer.next('See [link][1]\n\n', nextOptions()); renderer.next('[1]: https://example.com "Example"\n\nTrailing', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![1]).toBe('

Trailing

'); expect(container.querySelector('a[href="https://example.com"]')?.textContent).toBe('link'); const { definitions } = renderer.finalize(nextOptions()); expect(definitions).toEqual([ expect.objectContaining({ identifier: '1', url: 'https://example.com', title: 'Example' }) ]); }); test('should extract definitions across committed and active paragraphs with shared link definitions', () => { const { container, nextOptions, renderer } = setup(); renderer.next( 'First paragraph [first][1]\n\n[1]: https://example.com "Example"\n\n' + 'Second paragraph [first][1] [second][2]\n\n', nextOptions() ); expect(getWrapperHTML(container)).toContain('First paragraph'); expect(getWrapperHTML(container)).toContain('Second paragraph'); expect(container.querySelectorAll('a[href="https://example.com"]').length).toBe(2); expect(container.querySelector('a[href="https://example.org"]')).toBeNull(); renderer.next('[2]: https://example.org "Other"', nextOptions()); expect(container.querySelector('a[href="https://example.org"]')?.textContent).toBe('second'); const { definitions } = renderer.finalize(nextOptions()); expect(definitions).toEqual([ expect.objectContaining({ identifier: '1', url: 'https://example.com', title: 'Example' }), expect.objectContaining({ identifier: '2', url: 'https://example.org', title: 'Other' }) ]); }); test('should preserve definitions for later blocks committed through incremental boundary', () => { const { container, nextOptions, renderer } = setup(); renderer.next( ['First [first][1]\n\n[1]: https://example.com "Example"\n\n', 'Second [first][1]\n\n'].join(''), nextOptions() ); expect(container.querySelectorAll('a[href="https://example.com"]').length).toBe(2); expect(getSentinel(container)).not.toBeNull(); renderer.next('Third [first][1]', nextOptions()); expect(container.querySelectorAll('a[href="https://example.com"]').length).toBe(3); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toContain('First'); expect(split![0]).toContain('Second'); expect(split![1]).toContain('Third'); expect(container.querySelectorAll('a[href="https://example.com"]').length).toBe(3); expect(container.querySelectorAll('a[href="https://example.com"]').item(0)?.textContent).toBe('first'); }); test('should extract link definitions after streaming content', () => { const { container, nextOptions, renderer } = setup(); renderer.next('See [link][1]\n\n', nextOptions()); renderer.next('[1]: https://example.com "Example"', nextOptions()); const { definitions } = renderer.finalize(nextOptions()); expect(definitions).toEqual([ expect.objectContaining({ identifier: '1', url: 'https://example.com', title: 'Example' }) ]); const linkElement = container.querySelector('a[href="https://example.com"]'); expect(linkElement).not.toBeNull(); expect(linkElement?.textContent).toBe('link'); }); }); describe('reset', () => { test('should clear state and render new content from scratch', () => { const { container, nextOptions, renderer } = setup(); renderer.next('First\n\nSecond', nextOptions()); expect(getSentinel(container)).not.toBeNull(); renderer.reset(); renderer.next('Fresh start', nextOptions()); expect(getWrapperHTML(container)).toBe('

Fresh start

'); expect(getSentinel(container)).toBeNull(); }); }); describe('finalize', () => { test('should do full reparse and remove sentinel', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Block 1\n\nBlock 2', nextOptions()); expect(getSentinel(container)).not.toBeNull(); renderer.finalize(nextOptions()); expect(getSentinel(container)).toBeNull(); expect(getWrapperHTML(container)).toContain('Block 1'); expect(getWrapperHTML(container)).toContain('Block 2'); }); test('should extract link definitions', () => { const { nextOptions, renderer } = setup(); renderer.next('See [link][1]\n\n[1]: https://example.com "Example"', nextOptions()); const { definitions } = renderer.finalize(nextOptions()); expect(definitions).toEqual( expect.arrayContaining([expect.objectContaining({ identifier: '1', url: 'https://example.com' })]) ); }); test('should render empty container when no markdown was passed', () => { const { container, nextOptions, renderer } = setup(); const { definitions } = renderer.finalize(nextOptions()); expect(getWrapperHTML(container)).toBe(''); expect(definitions).toEqual([]); }); }); describe('non-append reset and re-render', () => { test('should fall back to full reparse when new markdown does not start with previous', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Block 1\n\nBlock 2', nextOptions()); // Simulate non-append-only update (e.g., streaming content replaced entirely). renderer.reset(); renderer.next('Replaced\n\nContent', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toBe('

Replaced

'); expect(split![1]).toBe('

Content

'); }); }); describe('code blocks', () => { test('should split fenced code block from trailing paragraph', () => { const { container, nextOptions, renderer } = setup(); renderer.next('```js\nconsole.log("hi");\n```\n\nDone', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toContain('Done

'); }); }); describe('heading and paragraph', () => { test('should split heading from following paragraph', () => { const { container, nextOptions, renderer } = setup(); renderer.next('# Title\n\nBody text', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toBe('

Title

'); expect(split![1]).toBe('

Body text

'); }); }); describe('thematic break', () => { test('should split at thematic break', () => { const { container, nextOptions, renderer } = setup(); renderer.next('Above\n\n---\n\nBelow', nextOptions()); const split = splitBySentinel(container); expect(split).not.toBeNull(); expect(split![0]).toContain('Above'); expect(split![0]).toContain('Below

'); }); }); });