Static frontend with vendored marked/DOMPurify, bounded Markdown pipeline, same-origin mock server with scenario selection, unit, contract and CDP end-to-end tests under frontend/**.
150 lines
7.0 KiB
JavaScript
150 lines
7.0 KiB
JavaScript
/**
|
|
* Unit tests for render.js partitioning, export, and bounded history helpers.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
partitionMarkdown,
|
|
renderMarkdownSectionToFragment,
|
|
SECTION_TARGET_BYTES,
|
|
SECTION_HARD_LIMIT_BYTES,
|
|
PATHOLOGICAL_BLOCK_LIMIT,
|
|
getUtf8Bytes
|
|
} from '../js/render.js';
|
|
import { serializeBounded } from '../js/history.js';
|
|
|
|
describe('Render module helpers and partition logic', () => {
|
|
test('partitionMarkdown returns single section for normal sized text', () => {
|
|
const text = '# Small Document\n\nThis is a short markdown text.';
|
|
const sections = partitionMarkdown(text);
|
|
assert.equal(sections.length, 1);
|
|
assert.equal(sections[0].text, text);
|
|
});
|
|
|
|
test('partitionMarkdown splits large documents at heading/blank line boundaries', () => {
|
|
// Generate text exceeding SECTION_TARGET_BYTES (~48 KiB)
|
|
const part1 = '# Section One\n' + 'Content line.\n'.repeat(3500);
|
|
const part2 = '# Section Two\n' + 'More content line.\n'.repeat(3500);
|
|
const combined = `${part1}\n\n${part2}`;
|
|
|
|
const sections = partitionMarkdown(combined);
|
|
assert.ok(sections.length >= 2, 'Large text should be partitioned into at least 2 sections');
|
|
assert.ok(sections[0].text.includes('Section One'));
|
|
assert.ok(sections[1].text.includes('Section Two'));
|
|
});
|
|
|
|
test('partitionMarkdown forces split inside giant 12 MB code fence and re-opens fence', () => {
|
|
// 12 MB code fence (runtime probe from H1)
|
|
const line = 'const val = 1234567890;\n';
|
|
const linesCount = Math.ceil((12 * 1024 * 1024) / line.length);
|
|
const codeBlock = '```typescript\n' + line.repeat(linesCount) + '```\n';
|
|
|
|
const sections = partitionMarkdown(codeBlock);
|
|
assert.ok(sections.length > 100, `12 MB fence must be split into multiple sections, got ${sections.length}`);
|
|
|
|
// Every section must stay strictly bounded
|
|
for (let i = 0; i < sections.length; i++) {
|
|
const secBytes = getUtf8Bytes(sections[i].text);
|
|
assert.ok(secBytes <= SECTION_HARD_LIMIT_BYTES + 1024, `Section ${i} size ${secBytes} must be <= hard limit`);
|
|
|
|
// Each section must be a valid closed code block
|
|
const text = sections[i].text.trim();
|
|
assert.ok(text.startsWith('```typescript'), `Section ${i} must open with code fence`);
|
|
assert.ok(text.endsWith('```'), `Section ${i} must close with code fence`);
|
|
}
|
|
});
|
|
|
|
test('partitionMarkdown splits dense 2.2 MB list with no blank lines into bounded sections', () => {
|
|
// 2.2 MB dense list (runtime probe from H1)
|
|
const listItem = '- Item with detailed specification text and metadata\n';
|
|
const itemsCount = Math.ceil((2.2 * 1024 * 1024) / listItem.length);
|
|
const denseList = listItem.repeat(itemsCount);
|
|
|
|
const sections = partitionMarkdown(denseList);
|
|
assert.ok(sections.length >= 30, `2.2 MB dense list must be partitioned, got ${sections.length} sections`);
|
|
|
|
for (let i = 0; i < sections.length; i++) {
|
|
const secBytes = getUtf8Bytes(sections[i].text);
|
|
assert.ok(secBytes <= SECTION_HARD_LIMIT_BYTES + 512, `Section ${i} size ${secBytes} must not exceed hard limit`);
|
|
}
|
|
});
|
|
|
|
test('partitionMarkdown partitions ~122 MB simulated answer into bounded sections', () => {
|
|
// 122 MB simulated answer (from H1 runtime probe)
|
|
// Construct in chunks to avoid single-line issues
|
|
const chunk = '# Header\n' + 'Paragraph content line for analysis.\n'.repeat(1000); // ~37 KB
|
|
const chunkBytes = getUtf8Bytes(chunk);
|
|
const repetitions = Math.ceil((122 * 1024 * 1024) / chunkBytes);
|
|
const hugeDoc = chunk.repeat(repetitions);
|
|
|
|
const sections = partitionMarkdown(hugeDoc);
|
|
assert.ok(sections.length >= 1000, `122 MB document must partition into >1000 sections, got ${sections.length}`);
|
|
for (let i = 0; i < Math.min(20, sections.length); i++) {
|
|
assert.ok(getUtf8Bytes(sections[i].text) <= SECTION_HARD_LIMIT_BYTES + 512);
|
|
}
|
|
});
|
|
|
|
test('partitionMarkdown splits large tables at row boundaries and repeats table headers', () => {
|
|
const header = '| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n';
|
|
const row = '| data alpha | data beta | data gamma |\n';
|
|
const table = header + row.repeat(3000); // ~120 KB
|
|
|
|
const sections = partitionMarkdown(table);
|
|
assert.ok(sections.length >= 2, 'Large table should partition into multiple sections');
|
|
|
|
// Section 2 should continue with repeated table header
|
|
assert.ok(sections[1].text.includes('| Column 1 | Column 2 | Column 3 |'), 'Subsequent section must repeat table header');
|
|
assert.ok(sections[1].text.includes('|---|---|---|'), 'Subsequent section must repeat table separator');
|
|
});
|
|
|
|
test('partitionMarkdown handles multibyte UTF-8 byte boundary correctly', () => {
|
|
// 4-byte UTF-8 emoji
|
|
const emojiLine = '🚀'.repeat(500) + '\n'; // 2000 bytes per line
|
|
const multibyteDoc = emojiLine.repeat(40); // 80,000 bytes
|
|
|
|
const sections = partitionMarkdown(multibyteDoc);
|
|
assert.ok(sections.length >= 2, 'Should partition multibyte document based on byte size');
|
|
for (const sec of sections) {
|
|
assert.ok(getUtf8Bytes(sec.text) <= SECTION_HARD_LIMIT_BYTES + 2048);
|
|
}
|
|
});
|
|
|
|
test('partitionMarkdown falls back to bounded plain-text for single pathological line', () => {
|
|
// Single line exceeding 128 KiB
|
|
const hugeLine = 'A'.repeat(150 * 1024);
|
|
const sections = partitionMarkdown(hugeLine);
|
|
assert.ok(sections.length >= 1);
|
|
assert.ok(sections[0].isPathologicalFallback);
|
|
assert.ok(sections[0].text.includes('Block truncated for performance'));
|
|
assert.ok(sections[0].text.length < 35 * 1024);
|
|
});
|
|
|
|
test('renderMarkdownSectionToFragment fails safe when marked or DOMPurify absent', () => {
|
|
// In Node test environment, window.marked and window.DOMPurify are undefined
|
|
const rawUntrusted = '<script>alert("xss")</script><img src="x" onerror="alert(1)">';
|
|
const fragment = renderMarkdownSectionToFragment(rawUntrusted);
|
|
|
|
// Must return a safe DocumentFragment containing the render error notice, NEVER raw innerHTML
|
|
assert.ok(fragment);
|
|
if (typeof document !== 'undefined') {
|
|
const notice = fragment.querySelector('.render-error-notice');
|
|
assert.ok(notice, 'Must contain error notice when parser/sanitizer is absent');
|
|
assert.ok(!fragment.querySelector('script'), 'Must never inject script tags');
|
|
assert.ok(!fragment.querySelector('img'), 'Must never inject img tags');
|
|
}
|
|
});
|
|
|
|
test('serializeBounded bounds huge 10 MB objects to <= 32 KiB without memory exhaustion', () => {
|
|
const hugeObject = {
|
|
title: 'Large Tool Result',
|
|
markdown: 'x'.repeat(10 * 1024 * 1024), // 10 MB string
|
|
items: Array.from({ length: 50000 }, (_, i) => ({ id: i, name: `item_${i}` }))
|
|
};
|
|
|
|
const serialized = serializeBounded(hugeObject, 32 * 1024);
|
|
assert.ok(serialized.length <= 34 * 1024, `Serialized result length ${serialized.length} must be bounded around 32 KB`);
|
|
assert.ok(serialized.includes('Result display bounded to 32.0 KB'), 'Must include truncation indicator');
|
|
});
|
|
});
|