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 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 copies = []; const toasts = []; const saves = []; var nextSaveId = 1; const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, crypto: webcrypto, navigator: { clipboard: { writeText: async text => { copies.push(text); } } }, setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]], createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), fetchAssistantStatus: async () => ({ success: true }), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(body))); return { success: true, id: nextSaveId++ }; }, openAssistantStream: async () => new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Regenerated 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); } appBinding(context); t.after(() => window.close()); return { context, document: window.document, window, copies, toasts, saves }; } // The production page binds delegated clicks in bindEvents; the vm harness mirrors it. function appBinding(context) { context.bindEvents(); } function rows(app) { return app.document.querySelectorAll('#assistant-messages .assistant-msg'); } test('every message offers Copy (raw) and Translate; assistant messages also offer Regenerate', t => { const app = ui(t); const c = app.context; c.appendMessage('user', '**Question** with `code`'); c.appendMessage('assistant', 'Answer.', []); const userRow = rows(app)[0]; const assistantRow = rows(app)[1]; assert.ok(userRow.querySelector('[data-assistant-msg-copy]')); assert.ok(userRow.querySelector('[data-assistant-msg-translate]')); assert.equal(userRow.querySelector('[data-assistant-msg-regenerate]'), null, 'regenerate is assistant-only'); assert.ok(assistantRow.querySelector('[data-assistant-msg-copy]')); assert.ok(assistantRow.querySelector('[data-assistant-msg-translate]')); assert.ok(assistantRow.querySelector('[data-assistant-msg-regenerate]')); userRow.querySelector('[data-assistant-msg-copy]').click(); assert.equal(app.copies.length, 1); assert.equal(app.copies[0], '**Question** with `code`', 'copies the raw markdown, not the rendered HTML'); assistantRow.querySelector('[data-assistant-msg-copy]').click(); assert.equal(app.copies[1], 'Answer.'); }); test('regenerate replays the preceding question for the latest answer and never touches earlier turns', async t => { const app = ui(t); const c = app.context; c.appendMessage('user', 'First question'); c.appendMessage('assistant', 'First answer.', []); c.appendMessage('user', 'Second question'); c.appendMessage('assistant', 'Second answer.', []); assert.equal(rows(app).length, 4); const secondRow = rows(app)[3]; secondRow.querySelector('[data-assistant-msg-regenerate]').click(); assert.equal(app.document.getElementById('assistant-input').value, 'Second question', 'input refilled with the question'); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.equal(c.messages.length, 3, 'the replayed question is sent as the new message, not duplicated in history'); assert.equal(c.messages[2].content, 'Regenerated answer.'); assert.equal(c.messages[0].content, 'First question', 'earlier turns untouched'); assert.equal(c.messages[1].content, 'First answer.'); const all = rows(app); assert.match(all[all.length - 1].textContent, /Regenerated answer/); }); test('saved-chat rows open on click; the hover trash deletes without opening', async t => { const app = ui(t); const c = app.context; c.fetchSavedAssistantChats = async () => ({ success: true, chats: [{ id: 7, title: 'Stored chat', updated_at: new Date().toISOString() }] }); c.fetchSavedAssistantChat = async id => ({ success: true, chat: { id: id, title: 'Stored chat', payload: { version: 2, messages: [], sources: [], lastAnswer: '' } } }); await c.loadSavedChats(); const row = app.document.querySelector('[data-assistant-load-chat="7"]'); assert.ok(row, 'row rendered as the click target'); assert.ok(row.querySelector('[data-assistant-chat-menu="7"]'), 'hover options menu present'); row.click(); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.equal(String(c.currentChatId), '7', 'row click opens the chat'); const deleted = []; app.context.deleteSavedAssistantChat = async id => { deleted.push(id); return { success: true }; }; app.context.renameSavedAssistantChat = async () => ({ success: true }); row.querySelector('[data-assistant-chat-menu="7"]').click(); await new Promise(r => setImmediate(r)); const pop = app.document.querySelector('[data-assistant-chat-menu-pop]'); assert.ok(pop, 'options menu opens'); const actions = [...pop.querySelectorAll('[data-chat-action]')].map(b => b.getAttribute('data-chat-action')); assert.deepEqual(actions, ['rename', 'pin', 'export', 'delete'], 'Rename, Pin, Export, Delete'); pop.querySelector('[data-chat-action="delete"]').click(); await new Promise(r => setImmediate(r)); assert.deepEqual(deleted, ['7'], 'menu delete removes the chat without opening it'); }); test('regenerate on a non-latest answer refuses honestly without touching the chat', t => { const app = ui(t); const c = app.context; c.appendMessage('user', 'First question'); c.appendMessage('assistant', 'First answer.', []); c.appendMessage('user', 'Second question'); c.appendMessage('assistant', 'Second answer.', []); const firstRow = rows(app)[1]; firstRow.querySelector('[data-assistant-msg-regenerate]').click(); assert.equal(app.toasts.length, 1); assert.match(app.toasts[0][0], /latest answer/); assert.equal(c.messages.length, 4); assert.equal(app.copies.length, 0); }); test('regenerate is refused while the assistant is busy', async t => { const app = ui(t); const c = app.context; c.appendMessage('user', 'Question'); c.appendMessage('assistant', 'Answer.', []); c.assistantBusy = true; rows(app)[1].querySelector('[data-assistant-msg-regenerate]').click(); assert.equal(app.toasts.length, 1); assert.match(app.toasts[0][0], /still finishing/i); assert.equal(c.messages.length, 2); });