102 lines
5.3 KiB
JavaScript
102 lines
5.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 { JSDOM } = require('jsdom');
|
|
const { marked } = require('marked');
|
|
|
|
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
|
|
function ui(t) {
|
|
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 = marked;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const copies = [];
|
|
const downloads = [];
|
|
const context = { window, document: window.document, console, Blob, TextDecoder, AbortController,
|
|
URL: Object.assign(URL, { createObjectURL: () => 'blob:synthetic', revokeObjectURL() {} }),
|
|
navigator: { clipboard: { writeText: async text => { copies.push(text); } } },
|
|
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
saveAssistantChat: async () => ({ 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);
|
|
}
|
|
window.HTMLAnchorElement.prototype.click = function() { downloads.push({ href: this.href, download: this.download }); };
|
|
t.after(() => window.close());
|
|
return { context, document: window.document, window, copies, downloads };
|
|
}
|
|
|
|
const table = '| Item | Value |\n| :--- | ---: |\n| Alpha | 1.25 |\n| Beta | 2-4 |';
|
|
|
|
function bubble(app) { return app.document.querySelector('.assistant-msg.assistant .assistant-bubble'); }
|
|
|
|
test('tables carry OWUI copy-raw and export-csv behavioral hooks outside the table element', t => {
|
|
const app = ui(t);
|
|
app.context.appendMessage('assistant', table, []);
|
|
const wrapper = bubble(app).querySelector('.assistant-table-scroll');
|
|
assert.ok(wrapper, 'wrapper present');
|
|
const copy = wrapper.querySelector('[data-assistant-table-copy]');
|
|
const csv = wrapper.querySelector('[data-assistant-table-csv]');
|
|
assert.ok(copy && csv, 'both action buttons present');
|
|
assert.equal(wrapper.querySelector('table [data-assistant-table-copy], table [data-assistant-table-csv]'), null, 'buttons live outside the table');
|
|
copy.click();
|
|
assert.equal(app.copies.length, 1);
|
|
assert.match(app.copies[0], /\| Item \| Value \|/);
|
|
assert.match(app.copies[0], /\| :?-{3} \| ---: \|/, 'alignment survives the raw reconstruction');
|
|
assert.match(app.copies[0], /\| Alpha \| 1\.25 \|/);
|
|
assert.match(app.copies[0], /\| Beta \| 2-4 \|/);
|
|
csv.click();
|
|
assert.equal(app.downloads.length, 1);
|
|
assert.equal(app.downloads[0].href, 'blob:synthetic');
|
|
assert.equal(app.downloads[0].download, 'table.csv');
|
|
});
|
|
|
|
test('CSV export serializes rendered cells with a BOM', t => {
|
|
const app = ui(t);
|
|
const created = [];
|
|
const OriginalBlob = app.context.Blob;
|
|
app.context.Blob = class extends OriginalBlob { constructor(parts, opts) { super(parts, opts); created.push({ parts, opts }); } };
|
|
app.context.appendMessage('assistant', table, []);
|
|
bubble(app).querySelector('[data-assistant-table-csv]').click();
|
|
assert.equal(created.length, 1);
|
|
assert.equal(created[0].opts.type, 'text/csv');
|
|
const text = String(created[0].parts[0]);
|
|
assert.ok(text.startsWith('\uFEFF'), 'CSV starts with a BOM');
|
|
assert.match(text, /Item,Value/);
|
|
assert.match(text, /Alpha,1\.25/);
|
|
});
|
|
|
|
test('code blocks gain a copy button that copies decoded code text', t => {
|
|
const app = ui(t);
|
|
app.context.appendMessage('assistant', '```js\nconst ref = "[1]";\n```', []);
|
|
const block = bubble(app).querySelector('.assistant-codeblock');
|
|
assert.ok(block, 'code block wrapper present');
|
|
const button = block.querySelector('[data-assistant-code-copy]');
|
|
assert.ok(button);
|
|
assert.equal(block.querySelector('code').textContent, 'const ref = "[1]";');
|
|
button.click();
|
|
assert.equal(app.copies.length, 1);
|
|
assert.equal(app.copies[0], 'const ref = "[1]";');
|
|
});
|
|
|
|
test('copy failures never crash the chat flow', t => {
|
|
const app = ui(t);
|
|
app.context.navigator = { clipboard: { writeText: async () => { throw new Error('denied'); } } };
|
|
app.context.appendMessage('assistant', '```md\nx\n```', []);
|
|
bubble(app).querySelector('[data-assistant-code-copy]').click();
|
|
app.context.appendMessage('assistant', table, []);
|
|
var bubbles = app.document.querySelectorAll('.assistant-msg.assistant .assistant-bubble');
|
|
bubbles[bubbles.length - 1].querySelector('[data-assistant-table-copy]').click();
|
|
assert.equal(bubbles[bubbles.length - 1].querySelectorAll('table').length, 1, 'chat keeps rendering');
|
|
});
|