import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { chromium } from 'playwright'; test('StreamingRenderer: rAF coalescing and lifecycle cancellation', async () => { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); await page.route('http://streaming.test/**', async (route) => { const path = new URL(route.request().url()).pathname; if (path === '/') { return route.fulfill({ contentType: 'text/html', body: '
' }); } try { const source = await readFile(new URL('..' + path, import.meta.url), 'utf8'); return route.fulfill({ contentType: 'text/javascript', body: source }); } catch (err) { return route.abort(); } }); await page.goto('http://streaming.test/'); const result = await page.evaluate(async () => { const container = document.getElementById('root'); // Custom mock for rAF to control frame timing const originalRaf = window.requestAnimationFrame; const originalCancelRaf = window.cancelAnimationFrame; let rafId = 0; const pendingRfs = new Map(); window.requestAnimationFrame = (cb) => { const id = ++rafId; pendingRfs.set(id, cb); return id; }; window.cancelAnimationFrame = (id) => { pendingRfs.delete(id); }; const stepFrames = () => { const callbacks = Array.from(pendingRfs.values()); pendingRfs.clear(); for (const cb of callbacks) cb(performance.now()); return callbacks.length; }; const { createStreamRenderer } = await import('/static/js/streamingRenderer.js'); const options = { render: (text) => `${text}
` }; // Test 1: Fast bursts within a frame coalesce into a single rAF render const host1 = document.createElement('div'); container.appendChild(host1); const renderer1 = createStreamRenderer(host1, options); // Initial token renders synchronously to remove spinner immediately renderer1.update('A'); const textAfterFirst = host1.textContent; // Now stream 5 rapid tokens in the same frame renderer1.update('AB'); renderer1.update('ABC'); renderer1.update('ABCD'); renderer1.update('ABCDE'); // Before frame fires, only the scheduled pendingTail exists const textBeforeFrame = host1.textContent; const frameExecuted = stepFrames(); const textAfterFrame = host1.textContent; // Test 2: Finalize cancels pending rAF callback and freezes remaining text const host2 = document.createElement('div'); container.appendChild(host2); const renderer2 = createStreamRenderer(host2, options); renderer2.update('First'); renderer2.update('First, more rapid streaming'); const pendingBeforeFinalize = pendingRfs.size; renderer2.finalize(); const pendingAfterFinalize = pendingRfs.size; const textAfterFinalize = host2.textContent; // Executing any residual frames must do nothing because cancelAnimationFrame was called const residualSteps = stepFrames(); const textAfterResidual = host2.textContent; // Test 3: Reset clears state and cancels pending frames const host3 = document.createElement('div'); container.appendChild(host3); const renderer3 = createStreamRenderer(host3, options); renderer3.update('Start'); renderer3.update('Start and streaming tokens...'); renderer3.reset(); const pendingAfterReset = pendingRfs.size; // Test 4: Finalized blocks (\n\n) freeze synchronously const host4 = document.createElement('div'); container.appendChild(host4); const renderer4 = createStreamRenderer(host4, options); renderer4.update('Paragraph 1\n\n'); renderer4.update('Paragraph 1\n\nParagraph 2'); stepFrames(); renderer4.finalize(); // Test 5: DOM marker replacement during streaming preserves text across immediate finalize const host5 = document.createElement('div'); container.appendChild(host5); const renderer5 = createStreamRenderer(host5, options); renderer5.update('First chunk\n\n'); stepFrames(); // Simulate container rewrite by thinking indicator or tool card host5.innerHTML = '${text}
`; } }; const renderer6 = createStreamRenderer(host6, unstableOptions); renderer6.update('Stable start\n\n'); stepFrames(); crashTailRender = true; renderer6.update('Stable start\n\npending'); renderer6.update('Stable start\n\npending burst'); // Step frame: triggers renderTail inside rAF, which catches and falls back to full render stepFrames(); crashTailRender = false; renderer6.finalize(); const host6FinalText = host6.textContent.trim(); // Restore window.requestAnimationFrame = originalRaf; window.cancelAnimationFrame = originalCancelRaf; return { textAfterFirst, textBeforeFrame, frameExecuted, textAfterFrame, pendingBeforeFinalize, pendingAfterFinalize, textAfterFinalize, residualSteps, textAfterResidual, pendingAfterReset, host4Text: host4.textContent.trim(), host5FinalText, host6FinalText, tailCrashes, }; }); assert.equal(result.tailCrashes, 1, 'partial tail error path genuinely entered'); assert.equal(result.textAfterFirst, 'A', 'First token renders synchronously for immediate responsiveness'); assert.equal(result.frameExecuted, 1, 'Multiple rapid updates coalesce into a single animation frame'); assert.equal(result.textAfterFrame, 'ABCDE', 'Coalesced tail renders full updated content'); assert.equal(result.pendingBeforeFinalize > 0, true, 'Pending rAF was queued during rapid streaming'); assert.equal(result.pendingAfterFinalize, 0, 'finalize() successfully cancels pending animation frames'); assert.equal(result.textAfterFinalize, 'First, more rapid streaming', 'finalize() rendered complete final text'); assert.equal(result.residualSteps, 0, 'No orphaned rAF callbacks executed after finalize'); assert.equal(result.pendingAfterReset, 0, 'reset() cancels pending frames'); assert.equal(result.host4Text.includes('Paragraph 1') && result.host4Text.includes('Paragraph 2'), true); assert.equal( result.host5FinalText.includes('First chunk') && result.host5FinalText.includes('Second chunk following replace'), true, 'finalize() after self-healing preserves full text without erasing content' ); assert.equal( result.host6FinalText, 'Stable start\n\npending burst', 'rAF callback error triggers degraded full render without destroying content' ); } finally { await browser.close(); } }); test('combined rAF/full-render failures preserve authoritative text and recover canonically', async () => { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); await page.route('http://recovery.test/**', async route => { const path = new URL(route.request().url()).pathname; if (path === '/') return route.fulfill({ contentType: 'text/html', body: '' }); if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' }); return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }); await page.goto('http://recovery.test/'); const cases = await page.evaluate(async () => { const { createStreamRenderer } = await import('/static/js/streamingRenderer.js'); const { mdToHtml } = await import('/static/js/markdown.js'); const canonical = text => { const d = document.createElement('div'); d.innerHTML = mdToHtml(text); return d.innerHTML.replace(/>\s*\n\s*<').trim(); }; let next = 0; const pending = new Map(); const cancelled = []; window.requestAnimationFrame = cb => { pending.set(++next, cb); return next; }; window.cancelAnimationFrame = id => { if (pending.has(id)) cancelled.push(pending.get(id)); pending.delete(id); }; const frame = () => { const callbacks = [...pending.values()]; pending.clear(); callbacks.forEach(cb => cb()); }; const results = []; for (const recover of [false, true]) { const el = document.createElement('div'); let broken = false; let throws = 0; const renderer = createStreamRenderer(el, { render: text => { if (broken) { throws++; throw Error('both render paths fail'); } return mdToHtml(text); } }); renderer.update('prefix\n\ntail'); renderer.update('prefix\n\ntail plus'); broken = true; frame(); // auditor reproduction: both tail and full fallback must actually throw const afterFailure = el.textContent; if (recover) broken = false; renderer.finalize(); const expected = 'prefix\n\ntail plus'; results.push({ recover, throws, afterFailure, text: el.textContent, html: el.innerHTML, canonical: canonical(expected) }); broken = false; renderer.update(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd'); renderer.finalize(); results.at(-1).subsequentCanonical = el.innerHTML.replace(/>\s*\n\s*<').trim() === canonical(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd'); } // Broken first-token/synchronous path must also preserve text safely. const el = document.createElement('div'); const bad = createStreamRenderer(el, { render: () => { throw Error('always broken'); } }); bad.update('