const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); const { marked } = require('marked'); const { webcrypto } = require('node:crypto'); const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); function fakeTimers() { var nextId = 1; var pending = new Map(); return { pending: pending, setTimeout(fn, ms) { var id = nextId++; pending.set(id, { fn, ms }); return id; }, clearTimeout(id) { pending.delete(id); }, count() { return pending.size; }, delay() { return pending.size ? Array.from(pending.values()).map(e => e.ms).join(',') : ''; }, async flush() { while (pending.size) { var entries = Array.from(pending.values()); pending.clear(); for (var e of entries) await e.fn(); } } }; } function ui(t, options = {}) { const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); const window = dom.window; window.eval(read('public/js/accountBoundary.js')); assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true); window.marked = marked; window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); const timers = fakeTimers(); const toasts = []; const saves = []; var nextSaveId = 1; const chats = []; const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, crypto: webcrypto, setTimeout: timers.setTimeout.bind(timers), clearTimeout: timers.clearTimeout.bind(timers), showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]], createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), fetchAssistantStatus: async () => ({ success: true }), fetchAssistantExamples: async () => ({ success: true, examples: [] }), fetchSavedAssistantChats: async () => ({ success: true, chats }), fetchSavedAssistantChat: async id => ({ success: true, chat: chats.find(c => c.id === id) || { id, title: 'Stored', payload: { version: 2, messages: [], sources: [], lastAnswer: '' } } }), deleteSavedAssistantChat: async () => ({ success: true }), saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(body))); if (options.saveError) throw new Error(options.saveError); if (body.id) { var existing = chats.find(c => c.id === body.id); if (existing) { existing.payload = JSON.parse(JSON.stringify({ version: 2, messages: body.messages, sources: body.sources, lastAnswer: body.lastAnswer })); return { success: true, id: body.id }; } } var id = nextSaveId++; chats.push({ id, title: body.title || 'Derived', payload: { version: 2, messages: body.messages, sources: body.sources, lastAnswer: body.lastAnswer } }); return { success: true, id }; }, openAssistantStream: async () => new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete answer.', sources: [] }) + '\n\n') }; vm.createContext(context); for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) { vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); } t.after(() => window.close()); return { context, document: window.document, window, timers, toasts, saves }; } async function ask(app, text) { app.document.getElementById('assistant-input').value = text; await app.context.onAsk(); } test('autosave debounces completed turns into one save with a 60-character derived title, then updates in place', async t => { const app = ui(t); const c = app.context; const question = 'Synthetic pediatric asthma management question that is deliberately longer than sixty characters to verify title derivation'; await ask(app, question); await ask(app, 'Follow-up question about monitoring'); assert.equal(app.timers.count(), 1, 'two turns debounce into one pending save'); assert.equal(app.timers.delay(), '800'); await app.timers.flush(); assert.equal(app.saves.length, 1); assert.equal(app.saves[0].title, question.slice(0, 60), 'title derived from the first user message'); assert.equal(app.saves[0].id, undefined, 'first autosave creates the chat'); assert.equal(app.saves[0].generatedImage, undefined); assert.deepEqual(app.saves[0].messages.map(m => [m.role, m.content]), [ ['user', question], ['assistant', 'Complete answer.'], ['user', 'Follow-up question about monitoring'], ['assistant', 'Complete answer.'] ], 'raw transcript stored intact'); assert.equal(c.currentChatId, 1); await ask(app, 'Third question'); await app.timers.flush(); assert.equal(app.saves.length, 2); assert.equal(app.saves[1].id, 1, 'follow-up autosave updates the same chat'); assert.equal(app.saves[1].title, undefined, 'updates never overwrite the stored title'); assert.equal(app.saves[1].messages.at(-1).content, 'Complete answer.'); }); test('autosave failure surfaces exactly once and retries only on the next change', async t => { const app = ui(t, { saveError: 'Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated.' }); const c = app.context; await ask(app, 'A question that produces a too-large saved chat'); await app.timers.flush(); assert.equal(app.saves.length, 1); assert.equal(app.toasts.length, 1); assert.match(app.toasts[0][0], /8 MiB/); assert.equal(app.timers.count(), 0, 'no automatic retry loop'); await app.timers.flush(); assert.equal(app.saves.length, 1, 'flushing an empty timer queue does not resend'); c.appendMessage('user', 'Another change'); c.scheduleAutosave(); await app.timers.flush(); assert.equal(app.saves.length, 2, 'the next change retries'); assert.equal(app.toasts.length, 2, 'the retry failure surfaces once more'); assert.equal(c.messages.length, 3, 'failures never touch the live chat'); }); test('loading a saved chat binds autosave updates to that chat id', async t => { const app = ui(t); const c = app.context; c.chats = undefined; await c.loadSavedChat(7); // fetchSavedAssistantChat stub returns a stored payload for id 7 assert.equal(c.currentChatId, 7); await ask(app, 'Continuing the loaded chat'); await app.timers.flush(); assert.equal(app.saves.length, 1); assert.equal(app.saves[0].id, 7); }); test('new chat clears the autosave identity and pending timer; autosave derives the title and keeps updating one chat', async t => { const app = ui(t); const c = app.context; assert.equal(app.document.getElementById('btn-assistant-save'), null, 'manual Save control is gone — chats autosave'); assert.equal(app.document.getElementById('assistant-save-panel'), null); c.currentChatId = 5; c.appendMessage('user', 'Question'); c.scheduleAutosave(); assert.equal(app.timers.count(), 1); c.performClearConversation(); assert.equal(c.currentChatId, null); assert.equal(app.timers.count(), 0, 'pending autosave cancelled'); c.appendMessage('user', 'Fresh question'); c.appendMessage('assistant', 'Fresh answer.', []); c.lastAnswer = 'Fresh answer.'; c.scheduleAutosave(); await app.timers.flush(); assert.equal(app.saves.length, 1); assert.equal(app.saves[0].title, 'Fresh question', 'title derives from the first user message'); assert.equal(app.saves[0].id, undefined); assert.equal(c.currentChatId, 1); c.scheduleAutosave(); await app.timers.flush(); assert.equal(app.saves[1].id, 1, 'later autosaves update the same chat'); assert.equal(app.saves[1].title, undefined, 'updates keep the existing title (only the first save sets it)'); }); test('oversized conversations are sent whole and never client-truncated', async t => { const app = ui(t); const c = app.context; const big = 'X'.repeat(2000); c.appendMessage('user', 'Long question'); c.appendMessage('assistant', big, []); c.lastAnswer = big; c.scheduleAutosave(); await app.timers.flush(); assert.equal(app.saves[0].messages[1].content, big, 'content sent exactly'); assert.equal(c.messages[1].content, big); });