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('
', { 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(); // Eight, not nine: the signed-out preview is a feature flag and is saved by // the Feature Flags card now, not by this button. assert.equal(writes().length, 8); 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.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', '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')); 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

; writing it to the // 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. test('image model discovery sits beside TTS and STT, 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'); // Same shape as the two discovery cards it now sits between. ['admin-image-search', 'btn-discover-image', 'admin-image-discovered', 'admin-image-discover-hint'] .forEach(id => assert.ok(html.includes('id="' + id + '"'), 'admin.html has #' + id)); assert.ok(html.indexOf('id="admin-image-search"') > html.indexOf('id="admin-stt-search"'), 'the image card follows the STT card'); assert.ok(html.indexOf('id="admin-image-search"') < html.indexOf('id="admin-embed-search"'), 'and precedes embeddings'); assert.match(js, /if \(e\.target\.closest\('#btn-discover-image'\)\) discoverImageModels\(\);/); 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 from Image Generation and offered from the Clinical Assistant list', () => { 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'); 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'); });