The page had grown by accretion: model discovery scattered across five cards with a search box each, one Save writing eight keys from the bottom of a card that also held a second Save for something else, and a banner apologising that a button "applies only the settings above it". Now it reads in groups — Accounts, Models, Assistant & prompts, Site — and every card is a <details> that folds, so Save & Close means something. The rule is that each card saves exactly what it shows, which is what removed the need for the banner. Models is one workflow in three steps. Discover & test has a single search box and a kind switch (chat / image / speech / transcription / embedding); the five discovery calls are unchanged, the switch only decides which one answers. Roster is what has been added, including the image roster, which had no visible list before. Availability is the chat model, the two allowed lists, the per-workflow image settings and the slide reviewer, under one Save. Splitting the eight-key save follows from that rule: Save & Close writes the five retrieval and citation keys; Save availability writes the chat model, both allowed lists, the reviewer and the three image-settings PUTs. No route, request shape or setting key changed. Switching kind clears the results first — a row button would otherwise add an image model to the chat roster. The kind switch dispatches its event through document.defaultView's CustomEvent. jsdom refuses one built from another realm, and the existing announceModelsChanged() has exactly that bug: its event is built from the Node global, dispatchEvent refuses it, and a try/catch swallows the error — so models-changed propagation has only ever been source-grepped, never actually tested. Left alone here to keep this change to one subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
518 lines
32 KiB
JavaScript
518 lines
32 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.equal(ui.document.getElementById('btn-save-availability').disabled, true, 'both cards fed by this load are held');
|
|
// The failure is stated in an error box now rather than in a status line
|
|
// beside Save, where a bare 'Retry loading settings' button read like an
|
|
// ordinary control that was always there.
|
|
const errorBox = ui.document.getElementById('assistant-config-error');
|
|
assert.equal(errorBox.hidden, false, 'a failed load has to say so');
|
|
assert.match(errorBox.textContent, /could not be loaded/i);
|
|
assert.match(errorBox.textContent, /nothing you have typed has been lost/i);
|
|
assert.ok(ui.document.getElementById('btn-retry-assistant-config'),
|
|
'and the retry has to be reachable from inside it');
|
|
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');
|
|
// Empty means "no saved override" — see admin-clinical-assistant-wiring.
|
|
// The environment value is the placeholder so Save cannot promote it silently.
|
|
assert.equal(setting(ui, 'conversation-budget').value, '', 'no saved override, so the field stays empty');
|
|
assert.equal(setting(ui, 'conversation-budget').placeholder, '240000', 'environment metadata shows as the placeholder');
|
|
assert.equal(setting(ui, 'conversation-budget').readOnly, false);
|
|
// Idle no longer claims 'ready'. What an admin actually needs to know is
|
|
// whether their change was applied, so the line reports that instead.
|
|
assert.match(setting(ui, 'admin-status').textContent, /Settings loaded|Saved /i);
|
|
// The error box hides on a successful load; the retry inside it goes with it.
|
|
assert.equal(ui.document.getElementById('assistant-config-error').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();
|
|
// Save & Close writes only what the Clinical Assistant card shows. The chat
|
|
// model and the allowed lists are the Availability card's, saved below.
|
|
assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [
|
|
// Saving an untouched form must NOT turn the environment value into a
|
|
// stored override — empty is the "use the environment" signal.
|
|
['clinical_assistant.conversation_chars', ''],
|
|
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
|
|
['clinical_assistant.translate_provider', 'libretranslate'],
|
|
['clinical_assistant.show_sources', 'true']
|
|
]);
|
|
const settingsWrites = writes(ui).length;
|
|
ui.document.getElementById('btn-save-availability').click(); await tick();
|
|
assert.deepEqual(writes(ui).slice(settingsWrites).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [
|
|
['clinical_assistant.chat_model', 'saved-chat'],
|
|
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', ''],
|
|
['my_resources.review_model', '']
|
|
]);
|
|
});
|
|
|
|
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('image-model dropdowns keep saved selections through discovery failures and save every workflow', 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, maxModels: 3, workflows: {
|
|
clinical_assistant: { model: 'saved-image', fallbacks: ['saved-backup'], budget: 32000 },
|
|
learning_hub: { model: 'saved-image', fallbacks: [], budget: 32000 },
|
|
my_resources: { model: '', fallbacks: [], 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');
|
|
// The saved fallback survives a discovery failure the same way the primary
|
|
// does: it is seeded into the list rather than only offered from discovery.
|
|
const clinical = ui.document.querySelectorAll('#workflow-image-settings fieldset')[0];
|
|
assert.equal(clinical.querySelectorAll('select')[1].value, 'saved-backup', 'saved fallback kept');
|
|
|
|
// The image form has no Save of its own: the Availability card's one Save
|
|
// writes it along with the chat model, the allowed lists and the reviewer.
|
|
assert.equal(ui.document.querySelector('#workflow-image-settings button[type="submit"]'), null);
|
|
ui.document.getElementById('btn-save-availability').click();
|
|
// One await per workflow, so the queue needs draining more than once.
|
|
for (let i = 0; i < 6; i++) await tick();
|
|
const puts = () => ui.calls.filter(c => c.options.method === 'PUT' && c.url.includes('/api/admin/image-settings/'));
|
|
assert.equal(puts().length, 3, 'every workflow is saved, My Resources included');
|
|
assert.deepEqual(puts().find(c => c.url.endsWith('clinical_assistant')).body,
|
|
{ model: 'saved-image', budget: 32000, fallbacks: ['saved-backup'] });
|
|
assert.deepEqual(puts().find(c => c.url.endsWith('learning_hub')).body,
|
|
{ model: 'saved-image', budget: 32000, fallbacks: [] });
|
|
// My Resources chooses its model per request, so the form must not send one.
|
|
assert.deepEqual(puts().find(c => c.url.endsWith('my_resources')).body,
|
|
{ budget: 32000, fallbacks: [] });
|
|
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');
|
|
}
|
|
});
|