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 { JSDOM } = require('jsdom'); const { marked } = require('marked'); const MarkdownIt = require('markdown-it'); const { savedChatPayload } = require('../src/utils/clinicalConversation'); const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); const sources = [{ title: 'Synthetic A', page: 7 }, { title: 'Synthetic B', page_number: 19 }]; const table = '| Item | Value (mg/kg) | Notes | Sources |\n| :--- | ---: | :---: | --- |\n| Alpha | 1.25 | A - B | 2 |\n| Beta | 2-4 | unchanged | [1] |'; const collapse = text => text.replace(/\s+/g, ' ').trim(); function ui(t, parser = marked) { const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); const window = dom.window; window.eval(read('public/js/accountBoundary.js')); assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true); const style = window.document.createElement('style'); style.textContent = read('public/css/assistant.css'); window.document.head.appendChild(style); window.marked = parser; window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); const saves = []; const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, setTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]], createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(savedChatPayload(body)))); return { success: true }; } }; vm.createContext(context); for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'assistant/export.js', 'clinicalAssistant.js']) { vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); } t.after(() => window.close()); return { context, document: window.document, saves, window }; } function rows(element) { return [...element.querySelectorAll('tbody tr')].map(row => [...row.cells].map(cell => cell.textContent.trim())); } function bubble(app) { return app.document.querySelector('.assistant-msg.assistant .assistant-bubble'); } function reopen(app, content, version = 2, lastAnswer = content) { app.context.restoreSavedChat({ version, messages: [{ role: 'user', content: 'Synthetic question' }, { role: 'assistant', content, sources }], sources, lastAnswer }); } test('raw v2 actual save/load and SSE/fallback/append/export share all table cells and source pages', async t => { const app = ui(t); const c = app.context; for (const fallback of [false, true]) { c.clearConversation(); c.openAssistantStream = async () => new Response(fallback ? '' : 'event: done\ndata: ' + JSON.stringify({ answer: table, sources }) + '\n\n'); c.fetchAssistantChat = async () => ({ success: true, answer: table, sources }); const loading = c.appendLoadingMessage(); await c.streamAssistantResponse({}, loading); assert.deepEqual(rows(bubble(app)), [['Alpha', '1.25', 'A - B', 'src'], ['Beta', '2-4', 'unchanged', 'src']]); assert.match(bubble(app).querySelector('.assistant-cite').title, /Source 2: Synthetic B, page 19/); assert.equal(bubble(app).querySelectorAll('td')[1].getAttribute('align'), 'right'); assert.equal(app.window.getComputedStyle(bubble(app).querySelectorAll('td')[1]).textAlign, 'right'); assert.equal(bubble(app).querySelector('.assistant-table-scroll').getAttribute('tabindex'), '0'); await c.saveCurrentChat(); assert.equal(app.saves.at(-1).version, 2); assert.equal(app.saves.at(-1).messages[0].content, table); assert.equal(app.saves.at(-1).lastAnswer, table); c.restoreSavedChat(app.saves.at(-1)); assert.equal(rows(bubble(app)).length, 2); c.exportAnswerPdf(); const exported = app.document.querySelector('#assistant-export-modal'); assert.deepEqual(rows(exported), [['Alpha', '1.25', 'A - B', '2'], ['Beta', '2-4', 'unchanged', '1']]); for (const cite of exported.querySelectorAll('.assistant-cite')) { assert.ok(exported.querySelector(cite.getAttribute('href')), 'export citation resolves to its own reference'); } c.clearConversation(); c.appendMessage('assistant', table, sources); assert.equal(rows(bubble(app)).length, 2); } }); test('legacy collapsed complete tables recover for display only, including multiple tables', async t => { const app = ui(t); const raw = collapse(table + '\n\n' + table.replace(/Alpha/g, 'Gamma').replace(/Beta/g, 'Delta')); reopen(app, raw, 1); assert.equal(bubble(app).querySelectorAll('table').length, 2); assert.deepEqual(rows(bubble(app)).map(row => row[0]), ['Alpha', 'Beta', 'Gamma', 'Delta']); assert.equal(app.context.messages[1].content, raw); await app.context.saveCurrentChat(); assert.equal(app.saves[0].messages[1].content, raw); app.context.exportAnswerPdf(); assert.equal(app.document.querySelectorAll('#assistant-export-modal table').length, 2); }); test('long new tables survive serialization and both installed parsers without losing tail, caption, notes or units', async t => { for (const parser of [marked, null]) { const app = ui(t, parser); if (!parser) app.window.markdownit = MarkdownIt; const long = 'Table 1. Synthetic dose comparison\n\n' + table + '\n' + Array.from({ length: 700 }, (_, n) => '| Row ' + n + ' | 0.25 | A - B, 5-10 mg/kg | [2] |').join('\n') + '\n\nNote: No real clinical data.\n\n† Synthetic footnote.'; const saved = JSON.parse(JSON.stringify(savedChatPayload({ messages: [{ role: 'assistant', content: long, sources }], lastAnswer: long, sources }))); app.context.restoreSavedChat(saved); assert.equal(rows(bubble(app)).length, 702); assert.equal(rows(bubble(app)).at(-1)[0], 'Row 699'); assert.match(bubble(app).textContent, /Table 1\. Synthetic dose comparison/); assert.match(bubble(app).textContent, /† Synthetic footnote\./); assert.equal(saved.messages[0].content, long); } }); test('code, math, escaped pipes and URLs cannot become lists or steal source-column cells', t => { const app = ui(t); const special = '| Item | Notes | Sources |\n| --- | --- | --- |\n| Alpha | `A - B [1]` and $x - y$ and \\(a - b\\) | 2 |\n| Beta | a\\|b and [URL](https://example.test/a-b?q=1%7C2) | [1] |'; reopen(app, special); assert.equal(rows(bubble(app)).length, 2); assert.equal(rows(bubble(app))[0][1], 'A - B [1] and $x - y$ and \\(a - b\\)'); assert.equal(rows(bubble(app))[1][1], 'a|b and URL'); assert.equal(rows(bubble(app))[1][2], 'src'); assert.equal(bubble(app).querySelector('code .assistant-cite'), null); assert.equal(bubble(app).querySelector('a[href^="https:"]').getAttribute('href'), 'https://example.test/a-b?q=1%7C2'); for (const literal of ['`' + collapse(table) + '`', '~~~md\n' + collapse(table) + '\n~~~', ' ' + collapse(table), '$$' + collapse(table) + '$$', '\\[' + collapse(table) + '\\]', 'https://example.test/' + collapse(table).replace(/ /g, '%20')]) { reopen(app, literal, 1); assert.equal(bubble(app).querySelectorAll('table').length, 0, literal); assert.equal(bubble(app).querySelectorAll('li').length, 0, literal); } }); test('ambiguous, empty-cell and truncated legacy tables are unchanged and honestly limited', t => { const app = ui(t); for (const raw of [collapse(table).slice(0, -3), '| A | B | | --- | --- | | x | |', '| A | B | | --- | --- | | x | y | extra |']) { reopen(app, raw, 1); assert.equal(bubble(app).querySelectorAll('table').length, 0); assert.ok(bubble(app).textContent.includes(raw)); assert.match(bubble(app).textContent, /could not.*recover|cannot.*recover/i); assert.equal(app.context.messages[1].content, raw); } const withLiterals = '| A | B | | --- | --- | | `x - y` and $a - b$ | [URL](https://example.test/) | incomplete'; reopen(app, withLiterals, 1); assert.ok(bubble(app).textContent.includes(withLiterals)); }); test('legacy exact-limit prefix may display only retained lastAnswer, never manufacture missing content', async t => { const app = ui(t); const full = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | complete | [1] |').join('\n')); assert.ok(full.length > 12000 && full.length < 30000); const clipped = full.slice(0, 12000); reopen(app, clipped, 1, full); assert.equal(rows(bubble(app)).length, 452); assert.match(bubble(app).textContent, /retained.*lastAnswer/i); assert.equal(app.context.messages[1].content, clipped); await app.context.saveCurrentChat(); assert.equal(app.saves[0].messages[1].content, clipped); assert.equal(app.saves[0].lastAnswer, full); app.context.exportAnswerPdf(); assert.equal(app.document.querySelectorAll('#assistant-export-modal tbody tr').length, 452); for (const retained of [clipped, 'different ' + full]) { reopen(app, clipped, 1, retained); assert.match(bubble(app).textContent, /clipped|truncat/i); assert.doesNotMatch(bubble(app).textContent, /Row 449/); } reopen(app, clipped, 2, full); assert.doesNotMatch(bubble(app).textContent, /Row 449/, 'v2 does not infer truncation from an ordinary 12,000-character message'); }); test('all render entrypoints fail closed on missing sanitizer, malicious HTML or parser errors', t => { const app = ui(t); const malicious = table + '\n\nbad'; for (const mode of ['normal', 'no-sanitizer', 'throw']) { if (mode === 'no-sanitizer') delete app.window.DOMPurify; if (mode === 'throw') app.window.marked = { lexer: marked.lexer, parse() { throw new Error('Synthetic parser failure'); } }; reopen(app, malicious); app.context.exportAnswerPdf(); assert.equal(app.document.querySelector('script, [onerror], a[href^="javascript:"]'), null); assert.ok(bubble(app).textContent.includes('Alpha')); assert.ok(app.document.querySelector('#assistant-export-modal').textContent.includes('Beta')); } }); test('legacy recovery refuses missing boundaries; supports independent lines and literal syntax inside complete rows', t => { const app = ui(t); const escaped = '| Name | Notes | Source |\n| :--- | ---: | --- |\n| A | a\\|b and `x\\|y` and $x - y$ | 2 |\n| B | https://example.test/a?b=1%7C2 | 1 |'; reopen(app, collapse(escaped), 1); assert.equal(rows(bubble(app)).length, 2); assert.equal(rows(bubble(app))[0][1], 'a|b and x|y and $x - y$'); assert.equal(rows(bubble(app))[0][2], 'src'); reopen(app, collapse(table) + '\n\nCaption two\n\n' + collapse(table), 1); assert.equal(bubble(app).querySelectorAll('table').length, 2); for (const raw of ['| A | B | --- | --- | x | y |', '| A | B | | --- | --- | | x | y', '| A | B | | --- | --- | | x | y | trailing prose']) { reopen(app, raw, 1); assert.equal(bubble(app).querySelectorAll('table').length, 0); assert.ok(bubble(app).textContent.includes(raw)); assert.match(bubble(app).textContent, /could not safely recover/i); } }); test('math/code literals and sentinel-shaped input survive postprocessing without citation or HTML interpretation', t => { const app = ui(t); const expressions = []; app.window.katex = { renderToString(expression) { expressions.push(expression); return '' + expression.replace(/&/g, '&').replace(/'; } }; const raw = '| Item | Value | Sources |\n| --- | --- | --- |\n| Alpha | `a\\|b [2][1] $notmath$` and $x \\mid y [1]$ | 2 |\n| Beta | \\(a - b\\) | 1 |'; reopen(app, raw); assert.equal(rows(bubble(app)).length, 2); assert.equal(bubble(app).querySelector('code').textContent, 'a|b [2][1] $notmath$'); assert.equal(bubble(app).querySelector('code .katex, .katex .assistant-cite'), null); assert.deepEqual(expressions, ['x \\mid y [1]', 'a - b']); assert.equal(rows(bubble(app))[1][2], 'src'); for (const raw of ['$$\n' + table + '\n$$', '~~~md\n' + table + '\n[1][2] $code$\n~~~', '\uE000html:0\uE001 and `\uE000markdown:0\uE001 [2][1]`']) { reopen(app, raw); assert.equal(bubble(app).querySelectorAll('table, .assistant-cite').length, 0); } reopen(app, '[URL](https://example.test/[1][2]) and A - B and 5-10 mg/kg'); assert.equal(decodeURIComponent(bubble(app).querySelector('a').getAttribute('href')), 'https://example.test/[1][2]'); assert.match(bubble(app).textContent, /A - B and 5-10 mg\/kg/); reopen(app, '[URL](https://example.test/$notmath$)'); assert.equal(bubble(app).querySelector('a').getAttribute('href'), 'https://example.test/$notmath$'); assert.ok(!expressions.includes('notmath')); reopen(app, 'Alpha'); assert.equal(bubble(app).querySelector('span[title]').title, 'threshold > 1 [2][1] $notmath$'); assert.ok(!expressions.includes('notmath'), 'quoted HTML attributes are literal, including >, citations and math'); }); test('legacy loss provenance and retained raw extension survive v2 re-save and follow-up with strict guards', async t => { const app = ui(t); const full = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | retained | [1] |').join('\n')); const clipped = full.slice(0, 12000); reopen(app, clipped, 1, full); app.context.appendMessage('user', 'Follow-up'); app.context.appendMessage('assistant', 'New answer', sources); app.context.lastAnswer = 'New answer'; await app.context.saveCurrentChat(); const saved = app.saves[0]; assert.equal(saved.version, 2); assert.equal(saved.messages[1].content, clipped); assert.equal(saved.messages[1].retainedAnswer, full); assert.equal(saved.messages[1].legacyClipped, true); app.context.restoreSavedChat(saved); assert.equal(rows(bubble(app)).length, 452); assert.equal(app.context.messages[1].content, clipped); assert.equal(app.context.conversationSize(''), saved.messages.reduce((n, m) => n + m.content.length, 0), 'display-only extension is not silently sent as inference history'); for (const extension of [clipped, 'not a prefix ' + full, full + 'x'.repeat(30000), full + '\n', undefined]) { const result = savedChatPayload({ messages: [{ role: 'assistant', content: clipped, legacyClipped: true, retainedAnswer: extension }] }); assert.equal(result.messages[0].retainedAnswer, undefined); assert.equal(result.messages[0].content, clipped); } const differentSources = { version: 1, messages: [{ role: 'assistant', content: clipped, sources: [{ title: 'Other', page: 99 }] }], sources, lastAnswer: full }; app.context.restoreSavedChat(differentSources); assert.doesNotMatch(bubble(app).textContent, /Row 449/); reopen(app, clipped, 1, clipped + 'x'.repeat(30000 - clipped.length)); assert.match(bubble(app).textContent, /30,000 characters.*absent content cannot be recovered/); }); test('parser-unavailable literals stay inert, and embedded chart initialization retains an attached bubble', t => { const app = ui(t, null); for (const raw of [' ' + collapse(table), '~~~md\n' + table + '\n~~~', '$$' + table + '$$']) { reopen(app, raw, 1); assert.equal(bubble(app).querySelectorAll('table, .assistant-cite').length, 0); assert.match(bubble(app).textContent, /Alpha/); } let rendered = 0; app.window.HTMLCanvasElement.prototype.getContext = function() { assert.equal(this.isConnected, true); return { canvas: this }; }; app.window.Chart = function(context, config) { assert.equal(context.canvas.isConnected, true); assert.equal(config.type, 'bar'); rendered++; }; app.context.appendMessage('assistant', '```chart\n{"type":"bar","data":{"labels":["[1]"],"datasets":[]}}\n```'); assert.equal(rendered, 1); }); test('clicking reused citation numbers selects the original turn source without changing saved history', async t => { const app = ui(t); const first = [{ number: 1, title: 'First turn reference', page: 11, excerpt: 'First turn context.' }]; const second = [{ number: 1, title: 'Second turn reference', page: 23, excerpt: 'Second turn context.' }]; app.context.restoreSavedChat({ version: 2, messages: [ { role: 'user', content: 'First question' }, { role: 'assistant', content: 'First answer. [1]', sources: first }, { role: 'user', content: 'Second question' }, { role: 'assistant', content: 'Second answer. [1]', sources: second } ], lastAnswer: 'Second answer. [1]', sources: second }); app.document.addEventListener('click', app.context.onAssistantDocumentClick); const citations = app.document.querySelectorAll('#assistant-messages .assistant-cite'); assert.equal(citations.length, 2); citations[0].click(); assert.match(app.document.querySelector('#assistant-source-1').textContent, /First turn reference.*page 11/is); assert.doesNotMatch(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference/); citations[1].click(); assert.match(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference.*page 23/is); citations[0].click(); await app.context.saveCurrentChat(); assert.deepEqual(app.saves[0].messages[1].sources, first); assert.deepEqual(app.saves[0].messages[3].sources, second); assert.deepEqual(app.saves[0].sources, second, 'viewing an older source does not replace the latest retrieval map'); assert.equal(app.saves[0].messages[1].content, 'First answer. [1]'); }); test('inline export reference activation preserves the preview while browser Back still closes it', t => { const app = ui(t); reopen(app, table); app.context.exportAnswerPdf(); const modal = app.document.querySelector('#assistant-export-modal'); const link = modal.querySelector('.assistant-cite[href="#ref-1-2"]'); const reference = modal.querySelector('#ref-1-2'); let scrolled = 0; reference.scrollIntoView = () => { scrolled++; }; const decoy = app.document.createElement('span'); decoy.id = reference.id; decoy.scrollIntoView = () => { throw new Error('Answer markup must not replace the reference target'); }; modal.querySelector('.answer').prepend(decoy); const originalHash = app.window.location.hash; const click = new app.window.MouseEvent('click', { bubbles: true, cancelable: true }); link.dispatchEvent(click); assert.equal(click.defaultPrevented, true, 'fragment navigation must not trigger the popstate close handler'); assert.equal(scrolled, 1); assert.equal(app.document.activeElement, reference); assert.equal(app.window.location.hash, originalHash); assert.equal(modal.isConnected, true); // Observe the application's decision, then stop jsdom's native navigation. let preventedByApp; modal.addEventListener('click', event => { preventedByApp = event.defaultPrevented; event.preventDefault(); }); for (const modifiers of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) { link.dispatchEvent(new app.window.MouseEvent('click', { bubbles: true, cancelable: true, ...modifiers })); assert.equal(preventedByApp, false, 'modified clicks retain native semantics'); assert.equal(scrolled, 1); } app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); assert.equal(app.document.querySelector('#assistant-export-modal'), null); }); test('comparison angles and compact URLs preserve every source-column link, export reference and raw saved turn', async t => { const fixtures = [ '| Age | Dose | Sources |\n| --- | --- | --- |\n| <1 month | 2 | 1 |\n| >1 month | 3 | 2 |', '| Item | Sources |\n|---|---|\n|https://example.test/reference|1|\n|Beta|2|', '| Item | Sources |\n|---|---|\n|https://example.test/a\\|b|1|\n|Beta|2|', '| Item | Sources |\n|---|---|\n||1|\n|Beta|2|' ]; for (const parser of [marked, null]) { const app = ui(t, parser); if (!parser) app.window.markdownit = MarkdownIt; for (const content of fixtures) { reopen(app, content); assert.equal(rows(bubble(app)).length, 2); assert.deepEqual([...bubble(app).querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']); await app.context.saveCurrentChat(); assert.equal(app.saves.at(-1).messages[1].content, content); app.context.exportAnswerPdf(); const modal = app.document.querySelector('#assistant-export-modal'); assert.deepEqual([...modal.querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']); for (const [number, page] of [[1, 7], [2, 19]]) { const link = modal.querySelector('.assistant-cite[data-source-number="' + number + '"]'); assert.match(modal.querySelector(link.getAttribute('href')).textContent, new RegExp('page ' + page)); } if (content.includes('<1 month')) assert.deepEqual(rows(modal).map(row => row[0]), ['<1 month', '>1 month']); if (content.includes('a\\|b')) assert.equal(decodeURIComponent(modal.querySelector('a[href^="https:"]').getAttribute('href')), 'https://example.test/a|b'); } } });