pediatric-ai-scribe-v3/test/frontend-prompt-env.test.js

475 lines
29 KiB
JavaScript

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 = ' </textarea><img src=x onerror="alert(1)"><script>alert(2)</script>\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('<div id="' + component + '-tab">' + read('public/components/' + component + '.html') + '</div>', { 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, context: values };
}
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 warns at exactly 90% and refuses above the cap without paid requests', async t => {
const ui = await browser(t, 'assistant', (url) => {
if (url.endsWith('/chat/stream')) return new Response('event: done\ndata: {"success":true,"answer":"Done","sources":[]}\n\n');
});
const warning = ui.document.getElementById('assistant-context-warning');
assert.equal(ui.document.getElementById('assistant-context-budget'), null, 'no constant counter — warnings only');
enter(ui, 'x'.repeat(900)); assert.equal(warning.hidden, false); assert.match(warning.textContent, /90%/);
enter(ui, 'x'.repeat(1001)); assert.match(warning.textContent, /Sending is blocked/);
await ask(ui);
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 0, 'over-cap ask never reaches the provider');
enter(ui, 'x'.repeat(500)); await ask(ui);
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 1, 'an under-cap ask proceeds');
});
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);
const bigDraft = 'x'.repeat(1001);
ui.document.getElementById('assistant-input').value = bigDraft;
await ask(ui);
assert.equal(ui.document.getElementById('assistant-input').value, bigDraft, '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.equal(setting(ui, 'conversation-budget').value, '240000', 'editable budget prefilled from environment metadata');
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'],
['clinical_assistant.conversation_chars', '240000'],
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', '']
]);
});
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.equal(setting(ui, 'conversation-budget').value, '', 'unavailable budget leaves the input empty');
});
});
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 = '<option value="7">Existing snapshot</option>';
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('<div id="admin-tab" class="tab-content active" data-component="admin" data-loaded="1">' + read('public/components/admin.html') + '</div>', { 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');
}
});