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', 'generatedImages.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.performAutosave(); 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); app.context.performAutosave(); 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); app.context.performAutosave(); 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'; app.context.performAutosave(); 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(); app.context.performAutosave(); 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']); app.context.performAutosave(); 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'); } } }); test('durable jobs preserve legacy provenance, provisional/clicked turn sources and private export section navigation', async t => { const app = ui(t); const c = app.context; const { webcrypto, createHash } = require('node:crypto'); const id = '12345678-1234-1234-1234-123456789abc'; const asset = '/api/generated-images/' + id; const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64'); const dataUrl = 'data:image/png;base64,' + png.toString('base64'); const context = { includedTurns: 2, totalTurns: 8, used: 31990, limit: 32000 }; const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); }; c.crypto = webcrypto; c.FileReader = class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes => { this.result = 'data:' + blob.type + ';base64,' + Buffer.from(bytes).toString('base64'); this.onload(); }); } }; app.window.getAuthHeaders = () => ({ Authorization: 'Bearer synthetic-only' }); c.fetch = async url => url.includes('/jobs/') ? new Response(JSON.stringify({ success: true, status: 'done', imageUrl: asset, jobId: id, context })) : new Response(png, { headers: { 'content-type': 'image/png', 'content-length': String(png.length), 'x-image-owner': 'synthetic-rendering-owner', 'x-image-sha256': createHash('sha256').update(png).digest('hex') } }); vm.runInContext(read('public/js/assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), c); c.imageStore = c.createAssistantImageStore(); const retained = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | retained | [1] |').join('\n')); const raw = retained.slice(0, 12000); c.restoreSavedChat({ version: 1, messages: [{ role: 'assistant', content: raw, sources, imageJobs: [{ jobId: id }] }], lastAnswer: retained, sources }); const firstTable = bubble(app).querySelector('table'); const firstHtml = firstTable.outerHTML; await tick(); assert.equal(bubble(app).querySelector('table'), firstTable); assert.equal(firstTable.outerHTML, firstHtml); assert.match(bubble(app).textContent, /2\/8 preceding turns included; 31990\/32000 UTF-16 code units/); assert.equal(c.messages[0].retainedAnswer, retained); assert.equal(c.messages[0].content, raw); const secondSources = [{ number: 1, title: 'New turn source', page: 41 }, { number: 2, title: 'New second source', page: 59 }]; const second = table + '\n\nCompare a < b > c, https://example.test/a?x=1&y=2 [1].'; let finish, sent; c.openAssistantStream = async payload => { sent = payload; return new Response(new ReadableStream({ start(controller) { const send = (event, data) => controller.enqueue(new TextEncoder().encode('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n')); send('sources', { sources: secondSources }); send('token', { token: second }); finish = () => { send('done', { answer: second, sources: secondSources, imageJobs: [{ jobId: id }] }); controller.close(); }; } })); }; c.bindEvents(); app.document.getElementById('assistant-input').value = 'Create a diagram'; const request = c.onAsk(); await tick(); assert.equal(sent.message, 'Create a diagram', 'image requests reach the real tool pathway, not a sidebar heuristic'); assert.deepEqual(JSON.parse(JSON.stringify(sent.history)), [{ role: 'assistant', content: raw }], 'retained display text and image metadata never enter inference'); const provisional = app.document.querySelector('.assistant-loading-msg') || app.document.querySelectorAll('.assistant-msg.assistant')[1]; const current = provisional.querySelector('.assistant-bubble'); current.querySelector('.assistant-cite').click(); assert.match(app.document.querySelector('#assistant-source-2').textContent, /New second source.*59/is); finish(); await request; await tick(); assert.equal(c.messages.at(-1).content, second); const lastTable = current.querySelector('table'); const lastHtml = lastTable.outerHTML; await tick(); assert.equal(current.querySelector('table'), lastTable); assert.equal(lastTable.outerHTML, lastHtml); bubble(app).querySelector('.assistant-cite').click(); assert.match(app.document.querySelector('#assistant-source-2').textContent, /Synthetic B.*19/is); await c.performAutosave(); const saved = app.saves.at(-1); assert.equal(saved.messages[0].content, raw); assert.equal(saved.messages[0].retainedAnswer, retained); assert.equal(saved.messages[0].legacyClipped, true); assert.deepEqual(saved.messages[0].sources, sources); assert.deepEqual(saved.sources, secondSources); assert.deepEqual(saved.messages[0].imageJobs, [{ jobId: id }]); assert.equal(saved.messages.at(-1).content, second); c.restoreSavedChat(saved); await tick(); const before = JSON.stringify(c.messages); await c.exporter.exportAnswerPdf({ messages: c.messages, lastAnswer: c.lastAnswer, lastSources: c.lastSources }); const modal = app.document.querySelector('#assistant-export-modal'); assert.equal(modal.querySelectorAll('img').length, 2); for (const img of modal.querySelectorAll('img')) assert.equal(img.getAttribute('src'), dataUrl); assert.equal(modal.querySelectorAll('tbody tr').length, 454); assert.match(modal.textContent, /retained.*lastAnswer/i); assert.match(modal.textContent, /New second source, page 59/); assert.equal(modal.querySelector('[align=right]').getAttribute('align'), 'right'); assert.equal(app.window.getComputedStyle(modal.querySelector('[align=right]')).textAlign, 'right'); const link = modal.querySelector('.assistant-cite[href="#ref-2-2"]'); const reference = modal.querySelector('#ref-2-2'); let scrolled = 0; reference.scrollIntoView = () => { scrolled++; }; link.dispatchEvent(new app.window.MouseEvent('click', { bubbles: true, cancelable: true })); assert.equal(scrolled, 1); assert.equal(app.document.activeElement, reference); assert.equal(modal.isConnected, true); app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); assert.equal(modal.isConnected, false); assert.equal(JSON.stringify(c.messages), before, 'private preparation and display provenance leave canonical messages unchanged'); });