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('
' + read('public/components/admin.html') + '
' + read('public/components/assistant.html') + '
', { 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\n\nbad'; 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(); } });