mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
merge(dev): integrate reviewed preview UI changes
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user