pediatric-ai-scribe-v3/test/admin-clinical-assistant-wiring.test.js
Daniel a505244b97
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
refactor: remove the embedding settings, whose only consumer is gone
Embeddings existed here for Learning Hub semantic search — the card said
so itself. Learning Hub was removed, and nothing took its place: the
clinical corpus is embedded by the indexing service, not by this app.
What was left was a settings page that configured a model, tested it,
reported its dimensions, and fed nothing.

src/utils/embeddings.js had exactly one importer, src/routes/adminConfig
.js, which used it for the three routes this deletes. Outside those, the
only mentions of embedding in the server were a comment and a settings
prefix.

Gone: the module, its three admin routes, the dimension probe, the
Discover & test kind and its two panels, the admin.js block behind them,
the embeddings. prefix from both the writable-settings allowlist and the
lockdown list (it can no longer be written at all, so locking it says
nothing), and docs/embeddings-setup.md, which documented Learning Hub
search end to end.

'embedding' stays in NON_CHAT_MODES — that is the filter keeping
embedding models out of the chat-model list, and the gateway still
serves them.

Docs still describe nine /api/learning endpoints that no longer exist,
left from the Learning Hub removal. Not touched here; that is its own
subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:47:09 +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']) {
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']) {
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']) {
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');
});