pediatric-ai-scribe-v3/test/clinical-release-integration.test.js

135 lines
8.7 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(() => {
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.match(document.getElementById('assistant-conversation-budget').textContent, /2,000 characters \(UTF-16 code units\)/);
document.getElementById('btn-save-assistant-config').click();
await tick();
assert.equal(limit, 2000);
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 4, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), false);
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick();
const input = document.getElementById('assistant-input');
input.value = 'x'.repeat(2001);
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();
}
});