Files
odysseus/tests/streamingRendererRaf.test.mjs
T

253 lines
12 KiB
JavaScript

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: '<!DOCTYPE html><html><body><div id="root"></div></body></html>' });
}
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) => `<p>${text}</p>` };
// 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 = '<div class="thinking">Thinking...</div>';
// Stream update on replaced DOM
renderer5.update('First chunk\n\nSecond chunk following replace');
stepFrames();
// Finalize immediately
renderer5.finalize();
const host5FinalText = host5.textContent.trim();
// Test 6: Asynchronous exception inside rAF callback falls back to degraded full render safely
const host6 = document.createElement('div');
container.appendChild(host6);
let crashTailRender = false;
let tailCrashes = 0;
const unstableOptions = {
render: (text) => {
if (crashTailRender && text === 'pending burst') {
tailCrashes++;
throw new Error('Simulated partial tail rAF render crash');
}
return `<p>${text}</p>`;
}
};
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: '<div id="root"></div>' });
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*</g, '><').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*</g, '><').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('<img src=x onerror=alert(1)>\n```js\nx\n'); bad.finalize();
results.push({ safeText: el.textContent, children: el.childElementCount });
// Pending callback, DOM rewrite, self-healing, then immediate finalize.
const host = document.createElement('div'); const healed = createStreamRenderer(host, { render: mdToHtml });
healed.update('A'); healed.update('AB'); host.innerHTML = 'thinking'; healed.update('ABC'); healed.update('ABCD'); healed.finalize();
const before = host.innerHTML; frame(); cancelled.forEach(cb => cb());
results.push({ healed: host.textContent, stable: before === host.innerHTML, pending: pending.size });
return results;
});
for (const result of cases.slice(0, 2)) {
assert.ok(result.throws >= 2, 'both incremental and full render actually threw');
assert.equal(result.afterFailure, 'prefix\n\ntail plus', 'complete safe text after failure');
if (result.recover) assert.equal(result.html.replace(/>\s*\n\s*</g, '><').trim(), result.canonical);
else assert.equal(result.text, 'prefix\n\ntail plus');
assert.equal(result.subsequentCanonical, true);
}
assert.equal(cases[2].safeText, '<img src=x onerror=alert(1)>\n```js\nx\n');
assert.equal(cases[2].children, 0, 'fallback never interprets HTML');
assert.deepEqual(cases[3], { healed: 'ABCD', stable: true, pending: 0 });
} finally { await browser.close(); }
});