429 lines
27 KiB
JavaScript
429 lines
27 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 { 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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { 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\n<img src=x onerror="alert(1)"><script>alert(2)</script><a href="javascript:alert(3)">bad</a>';
|
|
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 '<span class="katex">' + expression.replace(/&/g, '&').replace(/</g, '<') + '</span>'; } };
|
|
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, '<span title="threshold > 1 [2][1] $notmath$">Alpha</span>');
|
|
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|<https://example.test/reference>|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.doesNotMatch(bubble(app).textContent, /preceding turns included/);
|
|
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');
|
|
});
|