Sources (correcting what I built earlier) The previous toggle branched the SYSTEM PROMPT, so the same question could get a different answer depending on a display setting — the bias this was meant to avoid. The prompt is now unconditional: buildSystemPrompt takes no display argument and is byte-identical either way. Hiding sources happens on the way out — the server omits them and strips the now-orphaned [n] markers from the copy it sends. The answer is generated, stored and exported with citations intact, so turning the setting back on restores them without re-asking anything. Renamed to clinical_assistant.show_sources; the old key is still honoured. Signed-out preview (admin opt-in, default off) A visitor may try the assistant; reaching for the workspace asks them to sign in. Deliberately narrow: - Reachable paths are an exact allow-list, not a pattern, so a new endpoint is private unless someone adds it on purpose. - A preview visitor gets no identity at all (id: null), so nothing can be owned, saved, billed or addressed to them. - The image tool is withheld rather than left to fail on a null owner, and no audit rows are written. - A caller presenting a token is authenticated normally, so preview can never downgrade a real session; if the setting cannot be read, authentication is required. - Actions needing an account are hidden rather than offered and refused. Composer The bar above the transcript is gone. Patient take home, Export PDF, Download transcript and Attach images moved into a + menu in the composer, and the model selector moved beside send — shown only when there is more than one model, as before. Both views now start at the same top edge, so switching modes cannot nudge the page up or down. On an empty transcript the tiled ground runs behind and below the composer, which floats on it above centre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
138 lines
9.9 KiB
JavaScript
138 lines
9.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.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();
|
|
assert.equal(writes().length, 9);
|
|
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.preview_enabled', '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"/);
|
|
});
|
|
|