pediatric-ai-scribe-v3/test/admin-clinical-assistant-wiring.test.js
Daniel 9df2230479 refactor: rebuild the admin settings page around what each card does
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
2026-09-12 21:42:27 +02:00

233 lines
16 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, VirtualConsole } = require('jsdom');
const { Console } = require('node:console');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
// The page's console goes to stderr, never stdout. node:test reads each test
// file's results back over stdout as serialized frames; app.js's own log line
// ("✅ App.js loaded") written there could land inside a frame and fail the
// whole file with "Unable to deserialize cloned data" — about 1 run in 4.
function pageConsole() {
// jsdom 29 renamed sendTo() to forwardTo().
return new VirtualConsole().forwardTo(new Console({ stdout: process.stderr, stderr: process.stderr }));
}
function browserGlobals(t, dom, fetch, toasts) {
const values = { window: dom.window, document: dom.window.document, fetch, getAuthHeaders: () => ({ 'X-Test': 'synthetic' }), showToast: (...args) => toasts.push(args) };
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
Object.assign(global, values);
Object.assign(dom.window, { fetch, getAuthHeaders: values.getAuthHeaders });
t.after(() => {
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
dom.window.close();
});
}
test('native admin initializer preserves lazy navigation, assistant actions and read-only ENV budget metadata', async t => {
const dom = new JSDOM('<button class="tab-btn active" data-tab="home">Home</button><button class="tab-btn" data-tab="admin">Admin</button><div id="home-tab" class="tab-content" data-component="home" data-loaded="1"></div><div id="admin-tab" class="tab-content" data-component="admin"></div>', { runScripts: 'outside-only', url: 'https://app.example', virtualConsole: pageConsole() });
const calls = []; const toasts = [];
const fetch = async (url, options = {}) => {
calls.push({ url, options });
let data = {};
if (url.startsWith('/components/admin.html?')) return { ok: true, text: async () => read('public/components/admin.html') };
if (url === '/api/models') data = { models: [{ id: 'chat', name: 'Chat' }], defaultModel: 'chat' };
if (url === '/api/admin/config') {
assert.ok(document.getElementById('assistant-chat-model'), 'tabChanged fires after lazy markup exists');
data = { success: true, conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' }, config: [{ key: 'clinical_assistant.conversation_chars', value: '999999' }, { key: 'clinical_assistant.image_model', value: 'saved-image' }] };
}
if (url === '/api/admin/config/image-models/discover') data = { models: [{ id: 'image', name: 'Image' }] };
if (url === '/api/admin/clinical-assistant/prompt-pool') data = { success: true, meta: { count: 3 }, snapshots: [{ id: 7, count: 3 }] };
if (options.method) data = { success: true, duration: 1, response: 'Synthetic', meta: { count: 4 }, snapshots: [{ id: 7, count: 4 }] };
return { ok: true, json: async () => data };
};
browserGlobals(t, dom, fetch, toasts);
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
assert.equal(calls.length, 0, 'registration must not eagerly load assistant settings');
// JSDOM does not execute native script tags. Import the unmodified ESM above,
// then execute the real classic app entrypoint and its component loader.
await tick();
dom.window.eval(read('public/js/app.js'));
document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
await tick();
assert.equal(document.getElementById('assistant-chat-model'), null);
document.querySelector('[data-tab="admin"]').click();
await tick(); await tick();
assert.equal(document.getElementById('admin-tab').dataset.loaded, '1');
assert.equal(document.getElementById('assistant-chat-model').options[0].textContent, 'Use global default (chat)');
assert.equal(document.getElementById('assistant-image-model'), null, 'single image-model dropdown lives in workflow-image-settings');
assert.match(document.getElementById('assistant-prompt-pool-status').textContent, /3 prompts/);
const budget = document.getElementById('assistant-conversation-budget');
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
assert.equal(budget.type, 'number', 'the conversation budget is an editable admin input');
assert.equal(budget.readOnly, false, 'the budget is admin-editable');
assert.equal(budget.value, '999999', 'prefilled with the saved override');
const budgetMeta = document.getElementById('assistant-conversation-budget-meta').textContent;
assert.match(budgetMeta, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/);
assert.match(budgetMeta, /Clear this field/, 'the admin is told how to get back to the environment value');
assert.match(budgetMeta, /240,000/, 'and what that environment value currently is');
const initialConfigLoads = calls.filter(c => c.url === '/api/admin/config').length;
document.querySelector('[data-tab="home"]').click(); await tick();
document.querySelector('[data-tab="admin"]').click(); await tick();
assert.equal(calls.filter(c => c.url.startsWith('/components/admin.html?')).length, 1);
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, initialConfigLoads, 'initializer loads once across revisits');
const writes = () => calls.filter(c => c.options.method === 'PUT');
const save = document.getElementById('btn-save-assistant-config');
save.click(); await tick();
// Each card saves exactly what it shows. Save & Close on the Clinical
// Assistant card writes the five retrieval/citation/translation/budget keys;
// the chat model and the two allowed lists belong to the Availability card
// and are written by its own Save below. The signed-out preview is a feature
// flag saved by the Feature Flags card, not by either.
assert.equal(writes().length, 5);
assert.equal(writes().filter(c => /preview/.test(c.url)).length, 0,
'this button no longer writes the preview flag');
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider'
]);
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
assert.equal(save.closest('details').open, false, 'Save & Close folds the card once saved');
const before = writes().length;
document.getElementById('btn-save-availability').click(); await tick(); await tick();
const availability = writes().slice(before).map(c => c.url.split('/').pop());
// The per-workflow image settings are saved by the same button; that is
// covered where the image form can render (frontend-prompt-env).
for (const key of ['clinical_assistant.chat_model', 'clinical_assistant.allowed_models',
'clinical_assistant.allowed_image_models', 'my_resources.review_model']) {
assert.ok(availability.includes(key), 'Save availability writes ' + key);
}
document.getElementById('btn-test-assistant-chat-model').click(); await tick();
assert.deepEqual(JSON.parse(calls.find(c => c.url === '/api/admin/config/models/test').options.body), { modelId: 'chat' });
document.getElementById('btn-regenerate-assistant-prompt-pool').click(); await tick();
assert.equal(document.getElementById('btn-regenerate-assistant-prompt-pool').disabled, false);
document.getElementById('assistant-prompt-pool-snapshots').value = '7';
document.getElementById('btn-restore-assistant-prompt-pool').click(); await tick();
assert.deepEqual(JSON.parse(calls.find(c => c.url.endsWith('/prompt-pool/restore')).options.body), { id: 7 });
assert.equal(document.getElementById('assistant-custom-image-model'), null);
assert.equal(document.getElementById('btn-test-assistant-image-model'), null);
assert.equal(document.getElementById('btn-use-custom-assistant-image-model'), null);
await tick();
});
test('real extracted initializer never invents a cap when metadata is missing, invalid or returns 503', async t => {
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
for (const data of [{}, { success: false, error: 'Invalid environment' }, { success: true, conversationBudget: { limit: 1000001 } }]) {
await t.test(JSON.stringify(data), async t => {
const dom = new JSDOM(read('public/components/admin.html'));
browserGlobals(t, dom, async () => ({ status: 503, json: async () => data }), []);
initClinicalAssistantAdmin(value => value);
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
assert.equal(document.getElementById('assistant-conversation-budget').value, '', 'failed load leaves the budget input empty');
// The failure notice must land on the <p>; writing it to the <input>
// rendered nothing, so admins saw a stale message on a broken load.
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /Conversation budget unavailable/);
assert.doesNotMatch(document.getElementById('assistant-conversation-budget-meta').textContent, /Leave empty/, 'no misleading guidance when the load failed');
});
}
});
test('real extracted initializer displays the server default only when returned as metadata', async t => {
const dom = new JSDOM(read('public/components/admin.html'));
browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({ success: true, config: [], models: [], conversationBudget: {
limit: 120000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'default'
} }) }), []);
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
initClinicalAssistantAdmin(value => value);
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
// No saved override: the field stays EMPTY so Save cannot silently promote the
// environment value into a stored override. The number is shown as a placeholder.
const noOverride = document.getElementById('assistant-conversation-budget');
assert.equal(noOverride.value, '', 'no override means an empty field, not a prefilled one');
assert.equal(noOverride.placeholder, '120000', 'the effective limit is shown as a placeholder');
assert.equal(noOverride.readOnly, false);
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /built-in default/,
"source 'default' must not be reported as coming from the environment variable");
});
test('model availability comes from discovery, never hand-typed', () => {
const html = read('public/components/admin.html');
// A hand-typed id cannot be verified here, so the roster is exactly what the
// gateway advertises.
assert.doesNotMatch(html, /assistant-add-chat-model|assistant-add-image-model/, 'no manual entry field');
assert.doesNotMatch(html, /data-assistant-add-model/, 'and no Add button');
assert.match(html, /id="assistant-allowed-chat-models"/, 'the discovered roster stays');
assert.match(html, /id="assistant-allowed-image-models"/);
});
// Image models had a discovery ENDPOINT but no UI, so the only way to reach a
// newly added gateway model was to already know its id. Discovery now has one
// card for every kind of model, with a kind switch, so image discovery is a
// kind rather than a card of its own.
test('image model discovery is a kind in the shared Discover & test card, and ends in a test', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8');
const js = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8');
// One search box, one Search button, one result list and one hint for every kind.
['admin-discover-search', 'btn-discover', 'admin-discover-results', 'admin-discover-hint']
.forEach(id => assert.ok(html.includes('id="' + id + '"'), 'admin.html has #' + id));
for (const kind of ['chat', 'image', 'tts', 'stt', 'embedding']) {
assert.ok(html.includes('id="admin-discover-kind-' + kind + '"'), 'a kind switch for ' + kind);
}
assert.equal((html.match(/id="admin-discover-search"/g) || []).length, 1, 'exactly one search box');
// No leftover per-kind search boxes from the five cards this replaced.
for (const id of ['admin-model-search', 'admin-image-search', 'admin-tts-search', 'admin-stt-search', 'admin-embed-search']) {
assert.doesNotMatch(html, new RegExp('id="' + id + '"'), 'no separate #' + id);
}
// The kind switch dispatches; each discovery loader answers for its own kind.
assert.match(js, /CustomEvent\('admin-discover'/);
for (const kind of ['chat', 'image', 'tts', 'stt', 'embedding']) {
assert.match(js, new RegExp("e\\.detail\\.kind === '" + kind + "'\\) discover\\w+\\(\\);"), kind + ' listens');
}
assert.match(js, /'\/api\/admin\/config\/image-models\/discover\?q=' \+ encodeURIComponent\(search\)/,
'it calls the endpoint that already existed');
// Unlike a voice there is no single default to Set: an image model belongs to
// a workflow, so discovery ends in Test rather than Set.
assert.match(js, /admin-image-test-btn/);
assert.doesNotMatch(js, /admin-image-set-btn/);
assert.match(js, /'\/api\/admin\/config\/image-models\/test'/);
});
// The Image models list waited on a dropdown that no longer exists, so only four
// hard-coded fallbacks appeared and there was no way to add a gateway model.
test('image models are added under Discover & test, listed on the Roster, and offered under Availability', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const admin = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8');
const ca = fs.readFileSync(path.join(root, 'public/js/admin/clinicalAssistant.js'), 'utf8');
const server = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
assert.match(admin, /admin-image-add-btn/, 'each discovered model has + Add');
assert.match(admin, /'\/api\/admin\/config\/' \+ encodeURIComponent\('clinical_assistant\.image_model_roster'\)/);
assert.match(admin, /new CustomEvent\('assistant-image-roster-changed'/, 'and the list updates at once');
// The roster is visible as a list of its own, with the way off it, rather
// than only as ticks and as an "Added" badge on a row that had to be
// searched for again.
const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8');
assert.match(html, /id="admin-image-roster"/);
assert.match(admin, /function renderImageRoster\(\)/);
assert.match(admin, /admin-image-remove-btn/, 'each roster row has Remove');
assert.match(admin, /closest\('\.admin-image-add-btn, \.admin-image-remove-btn'\)/, 'Remove is the same toggle as Add');
assert.doesNotMatch(ca, /IMAGE_MODEL_FALLBACKS/, 'no hard-coded fallbacks');
assert.match(ca, /imageRosterSaved = parseAssistantList\(cfg\['clinical_assistant\.image_model_roster'\]\)/);
assert.match(ca, /imageRoster = imageRosterSaved\.concat\(\[window\._assistantImageModelValue\]\)/);
// A tick made but not saved survives an add from the other card.
assert.match(ca, /savedImageAllowed = checkedAssistantModels\('assistant-allowed-image-models'\);\s*\n\s*renderAssistantImageModelCheckboxes\(\);/);
assert.match(server, /key === 'clinical_assistant\.image_model_roster'/, 'the server validates the roster');
});