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
142 lines
9.3 KiB
JavaScript
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();
|
|
}
|
|
});
|