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