/** @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');
});
});
});