pediatric-ai-scribe-v3/test/clinical-release-integration.test.js
Daniel db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
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
2026-09-10 04:12:36 +02:00

139 lines
9.1 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);
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 9, '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();
}
});