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);
});