pediatric-ai-scribe-v3/test/clinical-release-integration.test.js
Daniel 96a6a353fc
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
fix: the assistant settings page says what saves what
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
2026-09-11 04:04:31 +02:00

140 lines
9.2 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);
// Eight since the signed-out preview moved to the Feature Flags card.
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 8, '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();
}
});