const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { JSDOM } = require('jsdom'); const root = path.join(__dirname, '..'); const read = file => fs.readFileSync(path.join(root, file), 'utf8'); const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); }; const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); const unsafe = ' \nšŸ˜€ retain spacing '; const catalogue = [ ...Array.from({ length: 29 }, (_, i) => ({ key: 'SCRIBE_' + i, dbKey: 'prompt.SCRIBE_' + i, family: 'scribe', revision: i ? 0 : 10 })), { key: 'clinical_assistant.system_behavior', dbKey: 'clinical_assistant.system_behavior', family: 'clinical-text', revision: 10 }, { key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', revision: 10 }, { key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image', revision: 10 } ].map(p => ({ ...p, value: unsafe, purpose: unsafe, usedBy: ['Synthetic runtime operation', unsafe], editable: true })); let moduleId = 0; async function browser(t, module, handler) { const component = module.startsWith('admin') ? 'admin' : 'assistant'; const dom = new JSDOM('
' + read('public/components/' + component + '.html') + '
', { url: 'https://synthetic.invalid', runScripts: 'outside-only' }); const { window } = dom; window.eval(read('public/js/accountBoundary.js')); assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true); const calls = []; const toasts = []; const fetch = async (url, options = {}) => { calls.push({ url, options, body: options.body && JSON.parse(options.body) }); const response = await handler?.(url, options); if (response) return response; if (url === '/api/admin/config/prompts') return json({ success: true, prompts: catalogue }); if (url === '/api/clinical-assistant/status') return json({ success: true, conversationChars: 1000 }); return json({ success: true, models: [], config: [], chats: [], examples: [] }); }; const values = { window, document: window.document, fetch, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }), showToast: (...args) => toasts.push(args), showConfirm: (message, accept) => accept() }; const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]); Object.assign(global, values); Object.assign(window, { getAuthHeaders: values.getAuthHeaders, showToast: values.showToast, confirm: () => true }); t.after(async () => { // Let pending debounced autosaves settle against THIS fetch mock before // globals are restored; otherwise a late 800ms autosave timer would post // through the next test's fetch and pollute its call log. await new Promise(resolve => setTimeout(resolve, 900)); for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; } window.close(); }); if (module === 'admin-settings') { const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href); initClinicalAssistantAdmin(value => value); } else { await import(pathToFileURL(path.join(root, 'public/js/' + (module === 'admin' ? 'admin' : 'clinicalAssistant') + '.js')).href + '?synthetic=' + ++moduleId); } window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: component } })); await tick(); return { window, document: window.document, calls, toasts }; } const sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : name === 'clinical' ? 'cms-clinical-prompts' : 'cms-learning-prompts'); const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : p.family === 'learning-image' ? 'learning' : 'clinical'); const familyOf = ui => ui.document.getElementById('cms-scribe-prompts'); const selectOf = family => family.querySelector('.prompt-select'); const draftOf = family => family.querySelector('.prompt-draft'); const statusOf = family => family.querySelector('.prompt-status'); const actionOf = (family, name) => family.querySelector('[data-prompt-action="' + name + '"]'); async function choosePrompt(family, dbKey) { const select = selectOf(family); select.value = dbKey; select.dispatchEvent(new family.ownerDocument.defaultView.Event('change', { bubbles: true })); await tick(); } async function clickAction(family, name) { actionOf(family, name).click(); await tick(); } test('three prompt sections keep Scribe, Clinical and Learning separate with inert exact text and canonical saves', async t => { const ui = await browser(t, 'admin', (url, options) => { if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 }); }); const scribe = sectionOf(ui, 'scribe'); const clinical = sectionOf(ui, 'clinical'); const learning = sectionOf(ui, 'learning'); assert.equal(selectOf(scribe).options.length, 29, 'Scribe stays as its own section'); assert.equal(selectOf(clinical).options.length, 2, 'Clinical TEXT and IMAGE share the Clinical section'); assert.equal(selectOf(learning).options.length, 1, 'Learning has its own section'); assert.equal(ui.document.querySelector('script, img, [onerror]'), null); assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default'); assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0'); assert.equal(draftOf(scribe).value, unsafe); for (const name of ['save', 'reset']) assert.ok(actionOf(scribe, name)); assert.equal(scribe.querySelector('.prompt-history'), null, 'no revision history UI'); for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) { const editor = sectionForPrompt(ui, p); await choosePrompt(editor, p.dbKey); assert.equal(draftOf(editor).value, unsafe); draftOf(editor).value = '\n' + unsafe + '\n'; await clickAction(editor, 'save'); const call = ui.calls.at(-1); assert.equal(call.url, '/api/admin/config/' + p.dbKey); assert.equal(call.options.method, 'PUT'); assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision }); assert.match(statusOf(editor).textContent, /Saved/); } await choosePrompt(scribe, catalogue[2].dbKey); assert.equal(draftOf(scribe).value, unsafe, 'other prompts unchanged'); }); test('restore original resets only the selected prompt and never replaces other drafts', async t => { const ui = await browser(t, 'admin', (url, options) => { if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 }); }); const scribe = sectionOf(ui, 'scribe'); const clinical = sectionOf(ui, 'clinical'); await choosePrompt(clinical, 'clinical_assistant.system_behavior'); draftOf(clinical).value = 'Unsaved clinical edits'; await choosePrompt(scribe, catalogue[1].dbKey); draftOf(scribe).value = 'Unrelated unsaved Scribe draft'; await choosePrompt(scribe, catalogue[0].dbKey); await choosePrompt(clinical, 'clinical_assistant.image_behavior'); draftOf(clinical).value = 'Unsaved image edits'; await clickAction(clinical, 'reset'); assert.equal(ui.calls.at(-1).url, '/api/admin/config/prompts/clinical_assistant.image_behavior/reset'); assert.equal(ui.calls.at(-1).options.method, 'POST'); assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 10 }); assert.equal(draftOf(clinical).value, 'new shipped default'); await choosePrompt(scribe, catalogue[1].dbKey); assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'reset never touches another prompt'); await choosePrompt(clinical, 'clinical_assistant.system_behavior'); assert.equal(draftOf(clinical).value, 'Unsaved clinical edits', 'reset never touches another prompt in the same section'); }); test('409s preserve drafts; revisiting the section refreshes the baseline for a clean save', async t => { let conflicting = true; const ui = await browser(t, 'admin', (url, options) => { if (options.method === 'PUT' || url.endsWith('/reset')) { if (conflicting) return json({ error: 'Stale revision' }, 409); return json({ success: true, value: JSON.parse(options.body).value, revision: 15 }); } }); const clinical = sectionOf(ui, 'clinical'); await choosePrompt(clinical, 'clinical_assistant.system_behavior'); draftOf(clinical).value = 'Keep this draft'; await clickAction(clinical, 'save'); assert.match(statusOf(clinical).textContent, /Conflict.*draft is unchanged/); assert.equal(draftOf(clinical).value, 'Keep this draft'); conflicting = false; // Revisiting the tab reloads the catalogue and refreshes the internal baseline. ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); await tick(); await choosePrompt(clinical, 'clinical_assistant.system_behavior'); draftOf(clinical).value = 'Keep this draft'; await clickAction(clinical, 'save'); assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 10 }); assert.match(statusOf(clinical).textContent, /Saved/); }); test('failed loads can retry; transport failures and in-flight saves preserve newer and unrelated drafts', async t => { let catalogueFailure = true; let failure = true; let release; const ui = await browser(t, 'admin', (url, options) => { if (url === '/api/admin/config/prompts' && catalogueFailure) return json({ error: 'Unavailable catalogue' }, 503); if (url.endsWith('/history?limit=100')) throw Error('Synthetic offline failure'); if (options.method === 'PUT') { if (failure) return json({ error: 'Migration unavailable' }, 503); return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); }); } }); const scribe = sectionOf(ui, 'scribe'); assert.match(scribe.textContent, /Unavailable catalogue/); assert.ok(scribe.querySelector('button'), 'retry control stays visible'); catalogueFailure = false; scribe.querySelector('button').click(); await tick(); const f = sectionOf(ui, 'clinical'); await choosePrompt(f, 'clinical_assistant.image_behavior'); draftOf(f).value = 'Preserved draft'; await clickAction(f, 'save'); assert.equal(draftOf(f).value, 'Preserved draft'); assert.match(statusOf(f).textContent, /Migration unavailable/); failure = false; actionOf(f, 'save').click(); await tick(); assert.equal(actionOf(f, 'save').disabled, true); assert.equal(actionOf(f, 'reset').disabled, true); draftOf(f).value = 'Newer draft typed during save'; release(); await tick(); assert.equal(draftOf(f).value, 'Newer draft typed during save'); assert.match(statusOf(f).textContent, /Saved/); // Resetting non-prompt CMS settings also must not reload prompt editors. ui.document.getElementById('btn-reset-all-defaults').click(); await tick(); assert.equal(draftOf(f).value, 'Newer draft typed during save'); }); async function loadChat(ui) { const load = ui.document.querySelector('[data-assistant-load-chat="1"]'); assert.ok(load); load.click(); await tick(); } function enter(ui, value) { const input = ui.document.getElementById('assistant-input'); input.value = value; input.dispatchEvent(new ui.window.Event('input')); return input; } async function ask(ui) { ui.document.getElementById('assistant-form').dispatchEvent(new ui.window.Event('submit', { cancelable: true })); await tick(); } test('native conversation UI counts UTF-16 history + draft, warns at exactly 90%, and refuses only above the cap without paid requests', async t => { const history = [{ role: 'user', content: 'x'.repeat(897) }, { role: 'assistant', content: 'šŸ˜€' }]; const ui = await browser(t, 'assistant', (url, options) => { if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 } ] }); if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } }); if (url.endsWith('/chat/stream')) return new Response('event: done\ndata: {"success":true,"answer":"Done","sources":[]}\n\n'); }); await loadChat(ui); const warning = ui.document.getElementById('assistant-context-warning'); assert.equal(ui.document.getElementById('assistant-context-budget'), null, 'no constant counter — warnings only'); enter(ui, ''); assert.equal(warning.hidden, true); enter(ui, 'x'); assert.equal(warning.hidden, false); assert.match(warning.textContent, /90%/); enter(ui, 'x'.repeat(101)); assert.match(warning.textContent, /At the conversation limit/); const input = enter(ui, 'x'.repeat(102)); await ask(ui); assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 0); assert.equal(input.value.length, 102); assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 2); assert.match(warning.textContent, /Sending is blocked/); for (const id of ['btn-assistant-download-chat', 'btn-assistant-export-pdf']) assert.equal(ui.document.getElementById(id).disabled, false); enter(ui, 'x'.repeat(101)); await ask(ui); const request = ui.calls.find(c => c.url.endsWith('/chat/stream')); assert.deepEqual(request.body.history, history); assert.equal(request.body.message.length, 101); assert.equal(input.value, ''); }); test('over-cap saved history remains viewable/autosavable/exportable', async t => { const history = [{ role: 'user', content: 'x'.repeat(1001) }, { role: 'assistant', content: 'Full saved answer' }]; const ui = await browser(t, 'assistant', (url, options) => { if (url === '/api/clinical-assistant/chats' && !options.method) return json({ success: true, chats: [{ id: 1 }] }); if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { version: 2, messages: history } } }); if (url === '/api/clinical-assistant/chat/stream') { return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Follow-up answer', sources: [] }) + '\n\n', { status: 200 }); } }); await loadChat(ui); enter(ui, 'Unsent draft'); // Over-cap chats cannot gain new turns (Sending is blocked), so nothing re-saves. ui.document.getElementById('assistant-input').value = 'Follow-up'; await ask(ui); assert.equal(ui.document.getElementById('assistant-input').value, 'Follow-up', 'the draft is preserved over an over-cap chat'); assert.equal(ui.calls.filter(c => c.url.endsWith('/chats') && c.options.method === 'POST').length, 0, 'no autosave without a completed turn'); ui.window.matchMedia = () => ({ matches: true }); ui.document.getElementById('btn-assistant-export-pdf').click(); assert.match(ui.document.getElementById('assistant-export-modal').textContent, /Full saved answer/); assert.equal(ui.document.getElementById('btn-assistant-download-chat').disabled, false); }); test('handoff controls are gone: the picker, modal and textarea no longer exist', async t => { const history = [{ role: 'user', content: 'šŸ˜€'.repeat(500) }]; const ui = await browser(t, 'assistant', (url, options) => { if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 }] }); if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } }); }); await loadChat(ui); enter(ui, 'Draft stays'); assert.equal(ui.document.getElementById('btn-assistant-handoff'), null); assert.equal(ui.document.getElementById('assistant-handoff-modal'), null); assert.equal(ui.document.getElementById('assistant-handoff-text'), null); assert.equal(ui.calls.filter(c => c.url.endsWith('/handoff')).length, 0); assert.equal(ui.document.getElementById('assistant-input').value, 'Draft stays'); assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 1); }); test('missing/invalid metadata never fabricates a cap; authoritative refusal keeps the draft and updates the counter', async t => { for (const metadata of [{ success: false }, { success: true, conversationChars: 1000001 }, { success: true }]) { await t.test(JSON.stringify(metadata), async t => { const ui = await browser(t, 'assistant', url => { if (url.endsWith('/status')) return json(metadata, 503); if (url.endsWith('/chat/stream')) return json({ error: 'Environment budget refused', budget: { limit: 1000 }, code: 'CONVERSATION_LIMIT' }, 413); }); enter(ui, 'šŸ˜€'.repeat(501)); assert.equal(ui.document.getElementById('assistant-context-budget'), null, 'no fabricated counter when the limit is unavailable'); await ask(ui); assert.equal(ui.document.getElementById('assistant-input').value, 'šŸ˜€'.repeat(501)); assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 0); assert.match(ui.document.getElementById('assistant-context-warning').textContent, /Sending is blocked/); await ask(ui); assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 1, 'subsequent refusal is local'); }); } }); const assistantConfig = () => ({ success: true, config: [ { key: 'clinical_assistant.chat_model', value: 'saved-chat' }, { key: 'clinical_assistant.image_model', value: 'saved-image' }, { key: 'clinical_assistant.search_limit', value: '17' }, { key: 'clinical_assistant.context_chars', value: '2300' } ], conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' } }); const adminVisit = ui => ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); const setting = (ui, name) => ui.document.getElementById('assistant-' + name); const writes = ui => ui.calls.filter(c => c.options.method === 'PUT'); async function forceAssistantSave(ui) { const save = ui.document.getElementById('btn-save-assistant-config'); save.click(); // dispatchEvent bypasses the disabled UI: the handler must also guard state. save.dispatchEvent(new ui.window.MouseEvent('click', { bubbles: true })); await tick(); } test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves drafts and successful revisit restores actual settings', async t => { let available = false; const ui = await browser(t, 'admin-settings', url => { if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503); }); await forceAssistantSave(ui); assert.equal(writes(ui).length, 0, 'failed configuration must never write defaults'); assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true); assert.match(setting(ui, 'admin-status').textContent, /failed|unavailable/i); assert.equal(setting(ui, 'chat-model').options.length, 0); assert.equal(ui.document.getElementById('workflow-image-settings').children.length, 0, 'image settings load only after config success'); assert.equal(ui.calls.some(c => c.url.endsWith('/image-models/discover')), false); setting(ui, 'search-limit').value = '21'; setting(ui, 'context-chars').value = '3100'; setting(ui, 'chat-model').appendChild(new ui.window.Option('Draft chat', 'draft-chat')); const retry = ui.document.getElementById('btn-retry-assistant-config'); assert.equal(retry.hidden, false); assert.equal(retry.type, 'button'); retry.click(); await tick(); assert.equal(setting(ui, 'search-limit').value, '21'); assert.equal(setting(ui, 'context-chars').value, '3100'); assert.equal(setting(ui, 'chat-model').value, 'draft-chat'); await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 2); available = true; adminVisit(ui); await tick(); assert.equal(setting(ui, 'chat-model').value, 'saved-chat'); assert.equal(setting(ui, 'search-limit').value, '17'); assert.equal(setting(ui, 'context-chars').value, '2300'); assert.match(setting(ui, 'conversation-budget').textContent, /240,000 characters \(UTF-16 code units\).*environment/); assert.match(setting(ui, 'admin-status').textContent, /ready/i); assert.equal(retry.hidden, true); adminVisit(ui); adminVisit(ui); await tick(); assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 3, 'ready revisits neither reload nor add handlers'); setting(ui, 'search-limit').value = '19'; ui.document.getElementById('btn-save-assistant-config').click(); await tick(); assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [ ['clinical_assistant.chat_model', 'saved-chat'], ['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'], ['clinical_assistant.translate_provider', 'libretranslate'] ]); }); test('assistant config pending, rejected, HTTP failure and malformed payloads never populate or save settings', async t => { const good = assistantConfig(); const failures = [ ['network', () => { throw Error('offline'); }], ['invalid JSON', () => new Response('{')], ['HTTP503 with valid-looking body', () => json(good, 503)], ...[null, {}, { ...good, success: false }, { ...good, config: undefined }, { ...good, config: {} }, { ...good, config: [null] }, { ...good, config: [{ key: 'clinical_assistant.chat_model', value: 42 }] }, { ...good, config: [{ value: 'missing key' }] }, { ...good, conversationBudget: undefined }, { ...good, conversationBudget: { ...good.conversationBudget, measure: 'tokens' } }, { ...good, conversationBudget: { ...good.conversationBudget, limit: 1000001 } } ].map((data, i) => ['malformed ' + i, () => json(data)]) ]; for (const [name, fail] of failures) await t.test(name, async t => { let release; const ui = await browser(t, 'admin-settings', url => { if (url === '/api/admin/config') return new Promise((resolve, reject) => { release = () => { try { resolve(fail()); } catch (error) { reject(error); } }; }); }); adminVisit(ui); adminVisit(ui); assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 1, 'loading revisits do not duplicate GET'); assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true); assert.match(setting(ui, 'admin-status').textContent, /loading/i); setting(ui, 'search-limit').value = '23'; await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); release(); await tick(); await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true); assert.equal(setting(ui, 'search-limit').value, '23'); assert.equal(setting(ui, 'chat-model').options.length, 0); assert.match(setting(ui, 'conversation-budget').textContent, /unavailable/); }); }); test('single image-model dropdown keeps saved selection through discovery failures and saves both workflows', async t => { const pending = []; const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config') return json(assistantConfig()); if (url === '/api/models') throw Error('chat discovery offline'); if (url === '/api/admin/image-settings') return json({ success: true, workflows: { clinical_assistant: { model: 'saved-image', budget: 32000 }, learning_hub: { model: 'saved-image', budget: 32000 } } }); if (url.endsWith('/image-models/discover')) return new Promise((resolve, reject) => { pending.push({ resolve, reject }); }); }); await tick(); await tick(); assert.equal(pending.length, 1, 'one discovery request issued'); pending[0].resolve(json({ error: 'unavailable' }, 503)); await tick(); await tick(); const select = ui.document.querySelector('#workflow-image-settings select'); assert.ok(select, 'image-model dropdown rendered'); assert.equal(select.value, 'saved-image', 'saved selection kept while discovery failed'); ui.document.querySelector('#workflow-image-settings form button').click(); await tick(); const puts = () => ui.calls.filter(c => c.options.method === 'PUT' && c.url.includes('/api/admin/image-settings/')); assert.equal(puts().length, 2); for (const workflow of ['clinical_assistant', 'learning_hub']) { assert.deepEqual(puts().find(c => c.url.endsWith(workflow)).body, { model: 'saved-image', budget: 32000 }); } assert.equal(select.value, 'saved-image', 'save never reverts the selection'); }); test('assistant settings retries leave global prompt drafts/history and starter snapshots untouched', async t => { let available = false; const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503); }); const f = sectionOf(ui, 'scribe'); draftOf(f).value = 'Unsaved global prompt'; setting(ui, 'prompt-pool-snapshots').innerHTML = ''; const before = f.outerHTML; const promptCalls = ui.calls.filter(c => c.url.includes('/config/prompts')).length; ui.document.getElementById('btn-retry-assistant-config').click(); await tick(); assert.equal(setting(ui, 'prompt-pool-snapshots').value, '7'); assert.equal(f.outerHTML, before); available = true; adminVisit(ui); await tick(); assert.equal(draftOf(f).value, 'Unsaved global prompt'); assert.equal(ui.calls.filter(c => c.url.includes('/config/prompts')).length, promptCalls); assert.equal(ui.calls.some(c => c.options.method === 'POST' || c.options.method === 'PUT'), false); }); test('admin tab already active at module init triggers loaders exactly once; guarded loaders never double-fire', async t => { const dom = new JSDOM('
' + read('public/components/admin.html') + '
', { url: 'https://synthetic.invalid', runScripts: 'outside-only' }); const { window } = dom; const calls = []; const fetch = async (url, options = {}) => { calls.push({ url, options }); if (url === '/api/admin/config/prompts') return json({ success: true, prompts: catalogue }); if (url === '/api/admin/config') return json({ success: true, config: [], conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' } }); if (url === '/api/admin/config/models') return json({ success: true, models: [], custom: [], defaultModel: '' }); return json({ success: true }); }; const values = { window, document: window.document, fetch, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }), showToast() {}, showConfirm: (message, accept) => accept() }; const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]); Object.assign(global, values); Object.assign(window, { getAuthHeaders: values.getAuthHeaders, showToast: values.showToast, confirm: () => true }); t.after(() => { for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; } window.close(); }); await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href + '?synthetic=' + ++moduleId); await tick(); assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'catalogue loader catches up exactly once at init'); assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'CMS config and assistant settings each load once at init'); assert.equal(calls.filter(c => c.url === '/api/admin/config/models').length, 1, 'model list catches up at init'); assert.equal(window.document.getElementById('cms-scribe-prompts').querySelectorAll('.prompt-select option').length, 29); window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); await tick(); assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'revisit never double-fires the catalogue loader'); assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'guarded assistant/CMS loaders never double-fire'); }); test('switching prompts within each section keeps every unsaved draft in memory', async t => { const ui = await browser(t, 'admin'); const scribe = sectionOf(ui, 'scribe'); const clinical = sectionOf(ui, 'clinical'); const learning = sectionOf(ui, 'learning'); await choosePrompt(scribe, catalogue[0].dbKey); draftOf(scribe).value = 'Scribe draft A'; await choosePrompt(scribe, catalogue[1].dbKey); assert.equal(draftOf(scribe).value, unsafe); draftOf(scribe).value = 'Scribe draft B'; await choosePrompt(clinical, 'clinical_assistant.system_behavior'); draftOf(clinical).value = 'Text draft'; await choosePrompt(clinical, 'clinical_assistant.image_behavior'); assert.equal(draftOf(clinical).value, unsafe); draftOf(clinical).value = 'Image draft'; await choosePrompt(learning, 'learning_hub.image_behavior'); draftOf(learning).value = 'Learning draft'; await choosePrompt(scribe, catalogue[0].dbKey); assert.equal(draftOf(scribe).value, 'Scribe draft A'); await choosePrompt(scribe, catalogue[1].dbKey); assert.equal(draftOf(scribe).value, 'Scribe draft B'); await choosePrompt(clinical, 'clinical_assistant.system_behavior'); assert.equal(draftOf(clinical).value, 'Text draft'); await choosePrompt(clinical, 'clinical_assistant.image_behavior'); assert.equal(draftOf(clinical).value, 'Image draft'); await choosePrompt(learning, 'learning_hub.image_behavior'); assert.equal(draftOf(learning).value, 'Learning draft'); }); test('catalogue loading failure always reaches a visible retry state, never an eternal spinner', async t => { const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config/prompts') return json({ error: 'Catalogue offline' }, 503); }); for (const name of ['scribe', 'clinical', 'learning']) { const f = sectionOf(ui, name); assert.match(f.textContent, /Catalogue offline/); assert.ok(f.querySelector('button'), 'retry button rendered'); assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains'); } });