mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-02 04:52:24 +02:00
290 lines
17 KiB
JavaScript
290 lines
17 KiB
JavaScript
import { test, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { chromium } from 'playwright';
|
|
|
|
// Execute the production browser entry functions with local network/session
|
|
// adapters. These are behavioral DOM tests, not assertions about source text.
|
|
const renderer = await readFile(new URL('../static/js/chatRenderer.js', import.meta.url), 'utf8');
|
|
const chat = await readFile(new URL('../static/js/chat.js', import.meta.url), 'utf8');
|
|
const addMessage = renderer.slice(renderer.indexOf('export function addMessage('), renderer.indexOf('\nconst chatRenderer =')).replace('export ', '');
|
|
const resume = chat.slice(chat.indexOf('export async function resumeStream('), chat.indexOf('\n /**\n * Check for background streams')).replace('export ', '');
|
|
let browser;
|
|
before(async () => { browser = await chromium.launch({ headless: true }); });
|
|
after(async () => { await browser?.close(); });
|
|
|
|
async function setup() {
|
|
const page = await browser.newPage();
|
|
await page.route('http://render.test/**', async route => {
|
|
const path = new URL(route.request().url()).pathname;
|
|
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<main id="chat-history"></main><button class="send-btn">Send</button>' });
|
|
if (path === '/static/js/ui.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default window.uiModule;' });
|
|
if (!path.startsWith('/static/')) return route.abort();
|
|
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
|
|
return route.fulfill({ contentType: 'text/javascript', body: source });
|
|
});
|
|
await page.goto('http://render.test/');
|
|
await page.evaluate(async ({ addMessage, resume }) => {
|
|
const noop = () => {};
|
|
const esc = value => { const node = document.createElement('div'); node.textContent = String(value ?? ''); return node.innerHTML; };
|
|
Object.assign(window, {
|
|
uiModule: { esc, scrollHistory: noop, showToast: value => window.toasts.push(value), showError: error => { throw Error(error); }, el: id => document.getElementById(id), captureHistoryScroll: () => ({ y: scrollY }), restoreHistoryScroll: s => scrollTo(0, s.y) },
|
|
hideWelcomeScreen: noop, resolveDocumentPlaceholderLinks: x => x,
|
|
replyModelPair: () => ({ actualModel: 'test', requestedModel: 'test' }),
|
|
modelRouteLabel: () => 'test', sameModelName: () => true, applyModelColor: noop,
|
|
roleTimestamp: () => document.createElement('span'),
|
|
createMsgFooter: () => { const node = document.createElement('div'); node.className = 'msg-footer'; node.textContent = 'Copy'; return node; },
|
|
displayMetrics: (node, metrics) => { node.dataset.metricsOwner = metrics.render_owner || ''; },
|
|
_suppressRawToolOutput: () => false, safeToolScreenshotSrc: () => '', _isPrivateBrowserTool: () => false,
|
|
_toolDisplayInfo: () => ({}), renderToolIcon: () => '',
|
|
buildSourcesBox: () => '<div class="sources-section">Sources</div>',
|
|
buildFindingsBox: () => '<div class="sources-section">Findings</div>',
|
|
buildRagSourcesBox: () => '<details class="rag-sources"><summary>Documents</summary></details>',
|
|
API_BASE: '', hasActiveStream: () => false,
|
|
_streamRunIds: new Map(), _resumingStreams: new Set(), _backgroundStreams: new Map(),
|
|
updateSubmitButton: noop, _shortModel: x => x, _applyModelColor: noop,
|
|
_streamDisplayText: x => x, createTerminalStreamError: x => Error(x.message || 'Stream error'),
|
|
_finishDocumentWritingStatus: noop,
|
|
spinnerModule: { create: () => { const node = document.createElement('span'); return { createElement: () => node, start: noop, destroy: () => node.remove() }; } },
|
|
reloads: 0, currentSession: 's', savedHistory: [], labels: [], toasts: [],
|
|
_setRoleModelLabel: (role, requested, actual) => { window.labels.push({ requested, actual }); role.textContent = requested + ' -> ' + actual; },
|
|
_metricsCostRecordId: () => 'test-run',
|
|
sessionModule: { getCurrentSessionId: () => window.currentSession, getSessions: () => [{ id: 's', model: 'test' }], markStreaming: noop, clearStreaming: noop, selectSession: () => { window.reloads++; }, loadSessions: () => { window.reloads++; } },
|
|
});
|
|
window.markdownModule = (await import('/static/js/markdown.js')).default;
|
|
markdownModule.renderMermaid = undefined;
|
|
window.createTurnRendering = (await import('/static/js/turnRendering.js')).createTurnRendering;
|
|
window.applyModelRouteEventState = (await import('/static/js/chatModelProvenance.js')).applyModelRouteEventState;
|
|
window.createTerminalStreamError = (await import('/static/js/chatStreamErrors.js')).createTerminalStreamError;
|
|
window.addMessage = (0, eval)('(' + addMessage + ')');
|
|
window.resumeStream = (0, eval)('(' + resume + ')');
|
|
window.chatRenderer = { addMessage: window.addMessage, recordSessionMetricsCost: noop, buildSourcesBox, buildFindingsBox, buildRagSourcesBox };
|
|
let controller;
|
|
const stream = new ReadableStream({ start(value) { controller = value; } });
|
|
window.send = event => controller.enqueue(new TextEncoder().encode('data: ' + (typeof event === 'string' ? event : JSON.stringify(event)) + '\n\n'));
|
|
window.sendError = event => controller.enqueue(new TextEncoder().encode('event: error\ndata: ' + JSON.stringify(event) + '\n\n'));
|
|
window.fetch = async url => String(url).includes('/api/chat/resume/')
|
|
? new Response(stream, { headers: { 'X-Odysseus-Run-Id': 'run-1' } })
|
|
: new Response(JSON.stringify({ history: window.savedHistory }));
|
|
}, { addMessage, resume });
|
|
return page;
|
|
}
|
|
|
|
test('history scoped ownership omits drafts for both owners, retains reasoning and tools, places answer last', async () => {
|
|
const page = await setup();
|
|
try {
|
|
for (const render_owner of ['structured', 'streamed']) {
|
|
const result = await page.evaluate(render_owner => {
|
|
document.querySelector('#chat-history').replaceChildren();
|
|
const metadata = { _fromHistory: true, _db_id: 42, render_owner, replacement_scope: 'turn', round_texts: ['<think>Lookup reasoning</think>Draft notes', 'Outdated answer'], tool_events: [{ round: 1, tool: 'manage_notes', output: 'Tool evidence', exit_code: 0 }] };
|
|
const original = JSON.stringify(metadata);
|
|
addMessage('assistant', '[Canonical note](#note-42)', 'test', metadata);
|
|
const root = document.querySelector('#chat-history');
|
|
return { text: root.textContent, link: root.querySelector('.body a')?.getAttribute('href'), unchanged: original === JSON.stringify(metadata), order: [...root.children].map(x => x.className), finalRaw: root.lastElementChild.dataset.raw };
|
|
}, render_owner);
|
|
assert.doesNotMatch(result.text, /Draft notes|Outdated answer/);
|
|
assert.match(result.text, /Lookup reasoning[\s\S]*Tool evidence[\s\S]*Canonical note/);
|
|
assert.equal(result.link, '#note-42');
|
|
assert.equal(result.unchanged, true);
|
|
assert.match(result.order.at(-1), /msg-ai/);
|
|
assert.equal(result.finalRaw, '[Canonical note](#note-42)');
|
|
}
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('legacy history keeps round prose without explicit replacement scope', async () => {
|
|
const page = await setup();
|
|
try {
|
|
const text = await page.evaluate(() => {
|
|
addMessage('assistant', 'Canonical', 'test', { _fromHistory: true, round_texts: ['Preamble', 'Old answer'], tool_events: [{ round: 1, tool: 'manage_notes' }] });
|
|
return document.querySelector('#chat-history').textContent;
|
|
});
|
|
assert.match(text, /Preamble[\s\S]*Canonical/);
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
async function startReplay(page) {
|
|
await page.evaluate(() => { window.running = resumeStream('s'); });
|
|
await page.waitForSelector('.msg-ai');
|
|
}
|
|
|
|
test('resume tool-only final is visible, scoped, and keeps the open timeline at completion', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
await page.evaluate(() => {
|
|
send({ type: 'tool_start', tool: 'manage_notes', command: '{}' });
|
|
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
|
|
});
|
|
await page.waitForSelector('.agent-thread-node:not(.running)');
|
|
await page.evaluate(() => {
|
|
window.thread = document.querySelector('.agent-thread');
|
|
thread.querySelector('.agent-thread-node').classList.add('open');
|
|
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
|
|
});
|
|
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
|
|
const result = await page.evaluate(async () => {
|
|
window.link = document.querySelector('.body a');
|
|
window.savedHistory = [{ role: 'assistant', content: '[Canonical](#note-42)', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn', round_texts: ['Stale draft', 'Canonical'], tool_events: [{ tool: 'manage_notes' }] } }];
|
|
send({ type: 'message_saved', id: 42, render_owner: 'structured' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { sameThread: thread === document.querySelector('.agent-thread'), sameLink: link === document.querySelector('.body a'), open: thread.querySelector('.agent-thread-node').classList.contains('open'), reloads, count: document.querySelectorAll('.body a').length, id: link.closest('.msg-ai').dataset.dbId, text: document.querySelector('#chat-history').innerText };
|
|
});
|
|
assert.deepEqual({ ...result, text: undefined }, { sameThread: true, sameLink: true, open: true, reloads: 0, count: 1, id: '42', text: undefined });
|
|
assert.doesNotMatch(result.text, /Stale draft/);
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume preserves legitimate scoped synthesis and rejects unscoped conflicting prose', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
const result = await page.evaluate(async () => {
|
|
send({ delta: 'Earlier draft', render_owner: 'streamed' });
|
|
send({ type: 'agent_step', round: 2 });
|
|
send({ type: 'final_response', content: 'Intermediate notes', render_owner: 'structured', replacement_scope: 'turn' });
|
|
send({ delta: 'Unscoped conflict', render_owner: 'streamed' });
|
|
send({ delta: 'Reasoning', thinking: true, render_owner: 'structured' });
|
|
send({ delta: 'Legitimate ', render_owner: 'streamed', replacement_scope: 'turn' });
|
|
send({ delta: 'synthesis', render_owner: 'streamed' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { text: document.querySelector('#chat-history').innerText, reloads };
|
|
});
|
|
assert.match(result.text, /Legitimate synthesis/);
|
|
assert.doesNotMatch(result.text, /Earlier draft|Intermediate notes|Unscoped conflict|Reasoning/);
|
|
assert.equal(result.reloads, 0);
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume fallback and provider alias remain visible without a history reload', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
const result = await page.evaluate(async () => {
|
|
send({ type: 'fallback', selected_model: 'selected-model', answered_by: 'fallback-model', reason: '429' });
|
|
send({ type: 'model_actual', model: 'provider/fallback-alias' });
|
|
send({ delta: 'hello' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { labels, toasts, reloads, holders: document.querySelectorAll('.msg-ai').length, role: document.querySelector('.role').textContent, text: document.querySelector('.body').innerText };
|
|
});
|
|
assert.deepEqual(result.labels, [{ requested: 'selected-model', actual: 'fallback-model' }, { requested: 'selected-model', actual: 'provider/fallback-alias' }]);
|
|
assert.deepEqual(result.toasts, ['Fallback: selected-model failed — answered by fallback-model']);
|
|
assert.equal(result.reloads, 0);
|
|
assert.equal(result.holders, 1);
|
|
assert.equal(result.text, 'hello');
|
|
assert.match(result.role, /provider\/fallback-alias/);
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume preoutput provider error stays visible as escaped text without reload', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
const result = await page.evaluate(async () => {
|
|
sendError({ status: 401, error: 'invalid key <img src=x>' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { text: document.querySelector('.body').innerText, images: document.querySelectorAll('img').length, reloads };
|
|
});
|
|
assert.deepEqual(result, { text: '[Error: invalid key <img src=x>]', images: 0, reloads: 0 });
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume terminal failure reconciles exact saved partial without reloading tool timeline', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
const result = await page.evaluate(async () => {
|
|
window.savedHistory = [
|
|
{ role: 'assistant', content: 'Correct partial [Agent stopped]', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn' } },
|
|
{ role: 'assistant', content: 'Wrong later answer', metadata: { _db_id: 43 } },
|
|
];
|
|
send({ delta: 'Partial' });
|
|
send({ type: 'message_saved', id: 42 });
|
|
send({ type: 'agent_terminal', data: { failure: { status: 429 } } });
|
|
sendError({ status: 429, error: 'Rate limited' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { text: document.querySelector('.body').innerText, id: document.querySelector('.msg-ai').dataset.dbId, reloads };
|
|
});
|
|
assert.deepEqual(result, { text: 'Correct partial [Agent stopped]', id: '42', reloads: 0 });
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume stable and complete events clear all round markers while preserving streamed prose and tools', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
await page.evaluate(() => {
|
|
send({ delta: 'First streamed round' });
|
|
send({ type: 'tool_start', tool: 'manage_notes' });
|
|
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
|
|
send({ type: 'agent_step', round: 2 });
|
|
send({ delta: 'Final streamed round' });
|
|
});
|
|
await page.waitForFunction(() => document.querySelector('#chat-history').innerText.includes('Final streamed round'));
|
|
await page.evaluate(() => {
|
|
window.prose = [...document.querySelectorAll('.body p')];
|
|
window.thread = document.querySelector('.agent-thread');
|
|
send({ type: 'stable' });
|
|
});
|
|
await page.waitForFunction(() => !document.querySelector('#chat-history .streaming'));
|
|
const result = await page.evaluate(async () => {
|
|
thread.classList.add('streaming');
|
|
send({ type: 'complete' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { sameProse: prose.every(p => p.isConnected), sameThread: thread === document.querySelector('.agent-thread'), markers: document.querySelectorAll('#chat-history .streaming').length, reloads };
|
|
});
|
|
assert.deepEqual(result, { sameProse: true, sameThread: true, markers: 0, reloads: 0 });
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume structured final and completion remove all transient round bubbles and streaming markers', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
await page.evaluate(() => {
|
|
send({ delta: 'Transient draft' });
|
|
send({ type: 'agent_step', round: 2 });
|
|
send({ delta: 'Another transient draft' });
|
|
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
|
|
});
|
|
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
|
|
const result = await page.evaluate(async () => {
|
|
const stableMarkers = document.querySelectorAll('#chat-history .streaming').length;
|
|
const link = document.querySelector('.body a');
|
|
send('[DONE]');
|
|
await running;
|
|
return { stableMarkers, markers: document.querySelectorAll('#chat-history .streaming').length, bodies: document.querySelectorAll('.msg-ai .body').length, same: link === document.querySelector('.body a'), text: document.querySelector('#chat-history').innerText };
|
|
});
|
|
assert.equal(result.stableMarkers, 0);
|
|
assert.equal(result.markers, 0);
|
|
assert.equal(result.bodies, 1);
|
|
assert.equal(result.same, true);
|
|
assert.doesNotMatch(result.text, /Transient|transient/);
|
|
} finally { await page.close(); }
|
|
});
|
|
|
|
test('resume error clears earlier and current streaming markers without dropping partial prose', async () => {
|
|
const page = await setup();
|
|
try {
|
|
await startReplay(page);
|
|
const result = await page.evaluate(async () => {
|
|
send({ delta: 'First partial' });
|
|
send({ type: 'agent_step', round: 2 });
|
|
send({ delta: 'Second partial' });
|
|
sendError({ status: 500, error: 'Provider failure' });
|
|
send('[DONE]');
|
|
await running;
|
|
return { markers: document.querySelectorAll('#chat-history .streaming').length, text: document.querySelector('#chat-history').innerText, reloads };
|
|
});
|
|
assert.equal(result.markers, 0);
|
|
assert.equal(result.reloads, 0);
|
|
assert.match(result.text, /First partial[\s\S]*Second partial[\s\S]*Provider failure/);
|
|
} finally { await page.close(); }
|
|
});
|