356 lines
21 KiB
JavaScript
356 lines
21 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' });
|
|
const window = dom.window;
|
|
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/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\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';
|
|
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|<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']);
|
|
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');
|
|
}
|
|
}
|
|
});
|