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-voice-owner' }, true), true); window.marked = marked; window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); const transcriptions = []; const ttsCalls = []; const played = []; const audioBlob = new Blob([new Array(300).join('a')], { type: 'audio/mpeg' }); function FakeRecorder() { this.started = false; this.stopped = false; this.start = () => { this.started = true; return Promise.resolve(); }; this.stop = () => { this.stopped = true; return Promise.resolve(options.blob || audioBlob); }; } function FakeRecognition() { this.onresult = null; this.onend = null; this.started = 0; this.start = () => { this.started += 1; }; this.stop = () => { if (this.onend) this.onend(); }; } function FakeAudio() { this.onended = null; this.onerror = null; this.play = () => { played.push(1); return Promise.resolve(); }; } const apiFetch = async (url, options) => { if (url === '/api/text-to-speech') { ttsCalls.push(JSON.parse(options.body)); return new Response(audioBlob, { status: 200 }); } if (url === '/clinical-assistant/translate/languages') return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en'] } })); return new Response(JSON.stringify({ success: true, chats: [] })); }; const context = { window, document: window.document, console, URL: window.URL, Blob, TextDecoder, AbortController, crypto: webcrypto, setTimeout() {}, clearTimeout() {}, showToast: () => {}, EMPTY_PROMPT_SETS: [[]], createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), saveAssistantChat: async () => ({ success: true }), fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }), translateAssistantMessage: () => Promise.resolve({ success: true }), getAuthHeaders: () => ({}), AudioRecorder: FakeRecorder, createSpeechRecognition: () => new FakeRecognition(), transcribeAudio: async blob => { transcriptions.push(blob); return { success: true, text: options.transcription || 'Dictated question', provider: 'synthetic' }; }, Audio: FakeAudio, CustomEvent: window.CustomEvent, fetch: apiFetch }; 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); } context.bindEvents(); t.after(() => window.close()); return { context, document: window.document, window, transcriptions, ttsCalls }; } test('composer mic dictation fills the input from the live transcript then the server transcription', async t => { const app = ui(t); app.document.getElementById('btn-assistant-mic').click(); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); const mic = app.document.getElementById('btn-assistant-mic'); assert.ok(mic.classList.contains('recording'), 'mic shows recording state'); app.document.getElementById('btn-assistant-mic').click(); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.ok(!mic.classList.contains('recording')); assert.equal(app.document.getElementById('assistant-input').value, 'Dictated question'); assert.equal(app.transcriptions.length, 1, 'audio blob sent to the transcription endpoint'); }); test('mic start failure reports the microphone denial honestly', async t => { const app = ui(t); const context = app.context; const orig = context.AudioRecorder; context.AudioRecorder = function() { this.start = () => Promise.reject(new Error('denied')); this.stop = () => Promise.resolve(); }; app.document.getElementById('btn-assistant-mic').click(); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.ok(!app.document.getElementById('btn-assistant-mic').classList.contains('recording')); context.AudioRecorder = orig; }); test('conversation mode listens, asks the transcribed question, and speaks the answer', async t => { const app = ui(t); const doc = app.document; doc.getElementById('btn-assistant-voice').click(); await new Promise(r => setImmediate(r)); const overlay = doc.getElementById('assistant-voice-overlay'); assert.ok(overlay, 'conversation overlay opens'); doc.getElementById('assistant-voice-mic').click(); await new Promise(r => setImmediate(r)); assert.equal(doc.getElementById('assistant-voice-status').textContent, 'Listening…'); doc.getElementById('assistant-voice-mic').click(); // stop → transcribe → ask await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.equal(doc.getElementById('assistant-input').value, 'Dictated question', 'transcribed question sent through the normal composer'); // The send click runs the real onAsk → openAssistantStream returns a done answer → setBusy(false) → answer-done event. const done = new app.window.CustomEvent('assistant-answer-done', { detail: { answer: 'Voice answer.' } }); doc.dispatchEvent(done); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.equal(app.ttsCalls.length, 1, 'answer spoken via TTS'); assert.equal(app.ttsCalls[0].text, 'Voice answer.'); doc.getElementById('assistant-voice-end').click(); await new Promise(r => setImmediate(r)); assert.equal(doc.getElementById('assistant-voice-overlay'), null, 'End closes the conversation'); }); test('conversation mode survives a failed answer and reports it honestly', async t => { const app = ui(t); const doc = app.document; doc.getElementById('btn-assistant-voice').click(); await new Promise(r => setImmediate(r)); doc.dispatchEvent(new app.window.CustomEvent('assistant-answer-done', { detail: { isError: true, answer: '' } })); assert.equal(doc.getElementById('assistant-voice-status').textContent, 'Answer failed — tap to speak'); assert.equal(app.ttsCalls.length, 0, 'no TTS after an error'); }); test('conversation mode falls back to the live transcript when server transcription fails', async t => { const app = ui(t, { transcription: '', failTranscribe: true }); const doc = app.document; app.context.transcribeAudio = async () => { throw new Error('down'); }; doc.getElementById('btn-assistant-voice').click(); await new Promise(r => setImmediate(r)); doc.getElementById('assistant-voice-mic').click(); await new Promise(r => setImmediate(r)); // feed a live final through the recognition callback before stopping const rec = app.context.createSpeechRecognition(); doc.getElementById('assistant-voice-mic').click(); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); assert.equal(doc.getElementById('assistant-input').value, '', 'no text without a transcript and without live speech'); });