112 lines
7.9 KiB
JavaScript
112 lines
7.9 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 = () => new Promise(resolve => setImmediate(resolve));
|
|
|
|
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' });
|
|
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.match(budget.textContent, /240,000 characters \(UTF-16 code units\).*CLINICAL_ASSISTANT_CONVERSATION_CHARS \(environment\)/);
|
|
assert.doesNotMatch(budget.textContent, /999/);
|
|
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();
|
|
assert.equal(writes().length, 4);
|
|
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
|
|
'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.search_limit', '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.match(document.getElementById('assistant-conversation-budget').textContent, /unavailable/);
|
|
assert.doesNotMatch(document.getElementById('assistant-conversation-budget').textContent, /120,?000|1,?000,?001/);
|
|
});
|
|
}
|
|
});
|
|
|
|
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();
|
|
assert.match(document.getElementById('assistant-conversation-budget').textContent, /120,000 characters \(UTF-16 code units\).*server default; environment unset/);
|
|
});
|