196 lines
10 KiB
JavaScript
196 lines
10 KiB
JavaScript
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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { 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); } } },
|
|
localStorage: { getItem: k => window.localStorage.getItem(k), setItem: (k,v) => window.localStorage.setItem(k,v) },
|
|
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('the send button becomes a clickable stop icon while the assistant works', async t => {
|
|
const app = ui(t);
|
|
const c = app.context;
|
|
const send = app.document.getElementById('btn-assistant-send');
|
|
assert.equal(send.querySelector('i').className, 'fas fa-arrow-up');
|
|
c.setBusy(true, 'Working');
|
|
assert.equal(send.disabled, false, 'still clickable so it can cancel');
|
|
assert.ok(send.classList.contains('busy'));
|
|
assert.equal(send.querySelector('i').className, 'fas fa-stop');
|
|
c.setBusy(false, 'Ready');
|
|
assert.equal(send.querySelector('i').className, 'fas fa-arrow-up');
|
|
assert.ok(!send.classList.contains('busy'));
|
|
});
|
|
|
|
test('editing a user message returns the question to the composer', async t => {
|
|
const app = ui(t);
|
|
const c = app.context;
|
|
c.appendMessage('user', '**Original** question?');
|
|
const row = app.document.querySelector('.assistant-msg.user');
|
|
const edit = row.querySelector('[data-assistant-msg-edit]');
|
|
assert.ok(edit, 'user messages offer Edit');
|
|
edit.click();
|
|
assert.equal(app.document.getElementById('assistant-input').value, '**Original** question?', 'raw question returns to the input');
|
|
});
|
|
|
|
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('chat menu pins a chat to the top and renames inline via the server', async t => {
|
|
const app = ui(t);
|
|
const c = app.context;
|
|
const older = { id: 2, title: 'Older chat', updated_at: '2026-09-01T00:00:00Z' };
|
|
const newer = { id: 9, title: 'Newer chat', updated_at: '2026-09-08T00:00:00Z' };
|
|
c.fetchSavedAssistantChats = async () => ({ success: true, chats: [older, newer] });
|
|
const renames = [];
|
|
c.renameSavedAssistantChat = async (id, title) => { renames.push([String(id), title]); return { success: true }; };
|
|
await c.loadSavedChats();
|
|
const rows = () => [...app.document.querySelectorAll('[data-assistant-load-chat]')];
|
|
assert.equal(rows()[0].getAttribute('data-assistant-load-chat'), '9', 'newest first before pinning');
|
|
rows()[1].querySelector('[data-assistant-chat-menu]').click();
|
|
await new Promise(r => setImmediate(r));
|
|
const pop = app.document.querySelector('[data-assistant-chat-menu-pop]');
|
|
pop.querySelector('[data-chat-action="pin"]').click();
|
|
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
|
assert.equal(rows()[0].getAttribute('data-assistant-load-chat'), '2', 'pinned chat jumps to the top');
|
|
assert.match(rows()[0].querySelector('.assistant-saved-chat-title').innerHTML, /fa-thumbtack/, 'pin icon shown');
|
|
rows()[0].querySelector('[data-assistant-chat-menu]').click();
|
|
await new Promise(r => setImmediate(r));
|
|
const pop2 = app.document.querySelector('[data-assistant-chat-menu-pop]');
|
|
pop2.querySelector('[data-chat-action="rename"]').click();
|
|
await new Promise(r => setImmediate(r));
|
|
const input = app.document.querySelector('.assistant-saved-chat-rename');
|
|
assert.ok(input, 'inline rename input appears');
|
|
input.value = 'Renamed chat';
|
|
input.dispatchEvent(new app.window.Event('blur'));
|
|
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
|
assert.deepEqual(renames, [['2', 'Renamed chat']], 'rename PATCH sent');
|
|
});
|
|
|
|
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);
|
|
});
|