pediatric-ai-scribe-v3/test/clinical-release-integration.test.js
Daniel 9df2230479 refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five
cards with a search box each, one Save writing eight keys from the
bottom of a card that also held a second Save for something else, and a
banner apologising that a button "applies only the settings above it".

Now it reads in groups — Accounts, Models, Assistant & prompts, Site —
and every card is a <details> that folds, so Save & Close means
something. The rule is that each card saves exactly what it shows,
which is what removed the need for the banner.

Models is one workflow in three steps. Discover & test has a single
search box and a kind switch (chat / image / speech / transcription /
embedding); the five discovery calls are unchanged, the switch only
decides which one answers. Roster is what has been added, including the
image roster, which had no visible list before. Availability is the
chat model, the two allowed lists, the per-workflow image settings and
the slide reviewer, under one Save.

Splitting the eight-key save follows from that rule: Save & Close writes
the five retrieval and citation keys; Save availability writes the chat
model, both allowed lists, the reviewer and the three image-settings
PUTs. No route, request shape or setting key changed.

Switching kind clears the results first — a row button would otherwise
add an image model to the chat roster.

The kind switch dispatches its event through document.defaultView's
CustomEvent. jsdom refuses one built from another realm, and the
existing announceModelsChanged() has exactly that bug: its event is
built from the Node global, dispatchEvent refuses it, and a try/catch
swallows the error — so models-changed propagation has only ever been
source-grepped, never actually tested. Left alone here to keep this
change to one subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:42:27 +02:00

142 lines
9.3 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { pathToFileURL } = require('node:url');
const { JSDOM } = require('jsdom');
const { normalizeMcpSearchResponse } = require('../src/utils/clinicalRetrieval');
const { savedChatPayload } = require('../src/utils/clinicalConversation');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
// Execute the actual static-route registration, without starting the application.
test('actual markup loads the existing local DOMPurify distribution through its vendor route', async t => {
const express = require('express');
const app = express();
const source = read('server.js');
const start = source.indexOf("app.use('/vendor/dompurify',");
const end = source.indexOf("app.use('/vendor/markdown-it',", start);
assert.ok(start >= 0 && end > start);
vm.runInNewContext(source.slice(start, end), { app, express, path, __dirname: root });
const server = app.listen(0, '127.0.0.1');
t.after(() => server.close());
await new Promise(resolve => server.on('listening', resolve));
const doc = new JSDOM(read('public/index.html')).window.document;
const scripts = [...doc.querySelectorAll('script[src]')].filter(script => /dompurify/i.test(script.src));
assert.equal(scripts.length, 1);
assert.equal(scripts[0].getAttribute('src'), '/vendor/dompurify/purify.min.js');
const response = await fetch('http://127.0.0.1:' + server.address().port + scripts[0].getAttribute('src'));
assert.equal(response.status, 200);
assert.match(response.headers.get('cache-control'), /max-age=3600/);
assert.equal(await response.text(), read('node_modules/dompurify/dist/purify.min.js'));
doc.defaultView.close();
});
test('native admin and assistant modules retain budget, table/source identity and safe live/saved/export rendering with or without DOMPurify', async t => {
const dom = new JSDOM('<div id="admin-tab">' + read('public/components/admin.html') + '</div><div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://app.example', runScripts: 'outside-only' });
const { window } = dom;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const document = window.document;
const calls = [];
const limit = 2000;
let saved;
const rows = Array.from({ length: 65 }, (_, i) => '| Drug ' + i + ' | 2 mg/kg |');
const sources = normalizeMcpSearchResponse({ results: [{ id: 42, title: 'Synthetic', page_number: 7, excerpt: 'Table 1. Synthetic\n\n| Drug | Dose |\n|---|---|\n' + rows.join('\n') + '\n\nNote: Synthetic only.' }] });
assert.ok(sources[0].excerpt.length > 900);
const markdown = '| Drug | Dose | Sources |\n|---|---|---|\n| Synthetic | 2 mg/kg | [1] |\n\n<img src=x onerror="alert(1)"><svg onload="alert(2)"></svg><script>alert(3)</script>\n\n<a href="javascript:alert(4)">bad</a>';
const fetchMock = async (url, options = {}) => {
calls.push({ url, options });
if (url === '/api/clinical-assistant/chat/stream') return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: markdown, sources }) + '\n\n');
let data = { success: true, models: [] };
if (url === '/api/admin/config') {
data.config = [{ key: 'clinical_assistant.conversation_chars', value: '999999' }];
data.conversationBudget = { limit, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' };
}
else if (url === '/api/clinical-assistant/status') data.conversationChars = limit;
else if (url === '/api/clinical-assistant/chats' && options.method === 'POST') saved = savedChatPayload(JSON.parse(options.body));
else if (url === '/api/clinical-assistant/chats') data.chats = saved ? [{ id: 1, title: 'Synthetic saved chat' }] : [];
else if (url === '/api/clinical-assistant/chats/1') data.chat = { payload: saved };
else if (url.endsWith('/examples')) data.examples = [];
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
};
const values = { window, document, fetch: fetchMock, showToast() {}, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }) };
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
Object.assign(global, values);
window.getAuthHeaders = values.getAuthHeaders;
window.showToast = values.showToast;
window.confirm = () => true;
window.marked = require('marked').marked;
window.matchMedia = () => ({ matches: true }); // Exercise the real inline export, without printing/downloading.
t.after(async () => {
await new Promise(resolve => setTimeout(resolve, 2000)); // let autosave + late fetch chains settle against this mock
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
window.close();
});
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
await import(pathToFileURL(path.join(root, 'public/js/clinicalAssistant.js')).href);
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
assert.equal(document.getElementById('assistant-conversation-budget').value, '999999');
assert.equal(document.getElementById('assistant-conversation-budget').readOnly, false, 'the budget is admin-editable');
document.getElementById('btn-save-assistant-config').click();
await tick();
assert.equal(limit, 2000);
// Five: Save & Close writes the Clinical Assistant card's own settings. The
// chat model and allowed lists are saved by the Availability card, and the
// signed-out preview by the Feature Flags card.
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 5, 'one native admin initializer; prompts are not generic setting saves');
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is an admin-settable override');
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick(); await tick(); await tick();
const input = document.getElementById('assistant-input');
input.value = 'x'.repeat(2001);
input.dispatchEvent(new window.Event('input'));
assert.match(document.getElementById('assistant-context-warning').textContent, /Sending is blocked/, 'the limit is known before the ask');
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
await tick();
assert.equal(input.value.length, 2001, 'over-budget draft retained');
assert.equal(calls.filter(call => call.url.endsWith('/chat/stream')).length, 0);
function assertSafe(element, purified) {
assert.ok(element);
assert.equal(element.querySelector('script, [onerror], [onload], a[href^="javascript:"]'), null);
if (purified) {
assert.equal(element.querySelectorAll('.assistant-table-scroll table tbody tr').length, 1);
const target = element.id === 'assistant-export-modal' ? '#ref-1-1' : '#assistant-source-1';
assert.equal(element.querySelector('.assistant-cite').getAttribute('href'), target);
assert.ok(document.querySelector(target), 'citation target exists in the displayed chat/export');
assert.match(element.querySelector('td').textContent, /Synthetic/);
} else {
assert.equal(element.querySelector('table, a, img, svg'), null, 'fallback is escaped text, not raw HTML');
assert.match(element.textContent, /onerror=/, 'unsafe markup remains inert text');
}
}
for (const purified of [false, true]) {
if (purified) window.eval(read('node_modules/dompurify/dist/purify.min.js'));
else delete window.DOMPurify;
document.getElementById('btn-assistant-clear').click();
input.value = 'Synthetic table question';
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
await tick();
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
await new Promise(resolve => setTimeout(resolve, 900)); // autosave debounce after the completed turn
assert.ok(saved, 'autosave persisted the completed turn');
assert.equal(saved.messages[1].content, markdown);
assert.equal(saved.sources[0].excerpt, sources[0].excerpt);
document.getElementById('btn-assistant-clear').click();
document.querySelector('[data-assistant-load-chat="1"]').click();
await tick();
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
const excerpt = document.querySelector('.assistant-source-excerpt p');
assert.equal(excerpt.textContent, sources[0].excerpt);
assert.equal(excerpt.style.whiteSpace, 'pre-wrap');
assert.match(excerpt.textContent, /Note: Synthetic only\.$/);
document.getElementById('btn-assistant-export-pdf').click();
assertSafe(document.querySelector('#assistant-export-modal'), purified);
document.querySelector('#assistant-export-modal #assistant-export-close').click();
}
});