Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
233 lines
16 KiB
JavaScript
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');
|
|
});
|