475 lines
29 KiB
JavaScript
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');
|
|
}
|
|
});
|