Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The card holds more than one Save button and nothing said so. "Save image settings" is injected directly above "Save model & retrieval settings", with no indication of where one stops and the other starts, and the page saves nothing automatically. It now states that up front, and the bottom button says which settings it applies. "Retry loading settings" sat beside Save looking like an ordinary control, because it did: a bare button with the hidden attribute, which the browser's own [hidden] rule could not hide once .btn-sm set a display. It is now inside an error message that exists only on failure, says what failed, and says that nothing typed has been lost. The status line used to read "Settings ready." forever, which answers a question nobody asks. It now reports the thing an admin actually wants to know when they come back: whether the last save went through, and at what time. A toast is gone in three seconds; this stays on the page. The signed-out preview moves to Feature Flags, where it belongs. It was a second checkbox under a row labelled "Sources", followed by two paragraphs, the first about preview and the second about citations — so neither paragraph clearly belonged to either checkbox. It is stored as feature.assistant_preview now, with the old clinical_assistant.preview_enabled still honoured when the new key has never been written. That also means an ordinary admin can toggle it under ADMIN_LOCKDOWN: clinical_assistant.* is locked, and putting a day-to-day switch behind host access was never the intent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
201 lines
14 KiB
JavaScript
201 lines
14 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();
|
|
// 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 <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.
|
|
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');
|
|
});
|