// A markdown table only parses once its last row has arrived. Re-parsing the // whole partial answer every 180ms therefore flashed between a broken parse and // the real thing, and the old answer was to abandon markdown past a size or a // pipe-row count and dump raw text into an unstyled
 — the black monospace
// block people saw while a table streamed.
//
// Now the text is split at the last finished block: everything before it is
// rendered once and appended, and only the unfinished tail is plain text.
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('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); const window = dom.window; window.eval(read('public/js/accountBoundary.js')); window.AccountBoundary.enter({ id: 'synthetic-streaming-owner' }, true); window.marked = marked; window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); 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 () => ({ 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; } const bubbleFor = ctx => ctx.document.createElement('div'); const TABLE = '| Drug | Dose |\n| --- | --- |\n| Adrenaline | 10 mcg/kg |\n| Amiodarone | 5 mg/kg |'; // ---- where the cut falls --------------------------------------------------- test('a finished paragraph settles, the one still arriving does not', (t) => { const { settledMarkdownLength } = ui(t); const text = 'First paragraph, complete.\n\nSecond one, still typ'; const cut = settledMarkdownLength(text, 0); assert.equal(text.slice(0, cut).trim(), 'First paragraph, complete.'); assert.equal(text.slice(cut).trim(), 'Second one, still typ'); }); test('a half-written table stays in the tail rather than parsing as broken', (t) => { const { settledMarkdownLength } = ui(t); const text = 'Resuscitation doses:\n\n| Drug | Dose |\n| --- | --- |\n| Adrenaline | 10 mc'; const cut = settledMarkdownLength(text, 0); assert.equal(text.slice(0, cut).trim(), 'Resuscitation doses:'); assert.ok(text.slice(cut).includes('| Adrenaline')); }); test('a blank line inside an open code fence is not a boundary', (t) => { const { settledMarkdownLength } = ui(t); // Cutting here would render a stray ``` and leave the rest unfenced. const text = 'Example:\n\n```js\nconst a = 1;\n\nconst b = 2;'; const cut = settledMarkdownLength(text, 0); assert.equal(text.slice(0, cut).trim(), 'Example:'); }); test('a closed fence settles, and the fence state resets after it', (t) => { const { settledMarkdownLength } = ui(t); const text = '```js\nconst a = 1;\n```\n\nAfter the block.\n\nStill writ'; const cut = settledMarkdownLength(text, 0); assert.ok(text.slice(0, cut).includes('After the block.')); assert.equal(text.slice(cut).trim(), 'Still writ'); }); test('the blank line inside a loose list is not a boundary', (t) => { const { settledMarkdownLength } = ui(t); // Cutting there renders one list as two, each restarting at 1. const text = 'Steps:\n\n1. Airway\n\n2. Breathing\n\n3. Circu'; const cut = settledMarkdownLength(text, 0); assert.equal(text.slice(0, cut).trim(), 'Steps:'); assert.ok(text.slice(cut).startsWith('1. Airway')); }); test('but the blank line before a list starts is a boundary', (t) => { const { settledMarkdownLength } = ui(t); // Rejecting on the following line alone would refuse this too, and a sentence // introducing a list is most of what this assistant writes. const text = 'Assess in this order:\n\n- Airway\n- Breath'; const cut = settledMarkdownLength(text, 0); assert.equal(text.slice(0, cut).trim(), 'Assess in this order:'); }); test('an unbroken paragraph settles nothing, which is the old behaviour', (t) => { const { settledMarkdownLength } = ui(t); const text = 'One very long sentence that has not finished yet and contains no blank line'; assert.equal(settledMarkdownLength(text, 0), 0); }); test('the search resumes from what was already consumed', (t) => { const { settledMarkdownLength } = ui(t); // A boundary already passed must never be returned again, or the same text // would be appended twice. const text = 'A.\n\nB.\n\nC still'; const first = settledMarkdownLength(text, 0); assert.ok(settledMarkdownLength(text, first) >= first); assert.equal(settledMarkdownLength('A.\n\nB still', 4), 4); }); // ---- what lands in the bubble ---------------------------------------------- test('the settled part is real markdown while the tail is still plain text', (t) => { const ctx = ui(t); const bubble = bubbleFor(ctx); ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE + '\n\n| Half | tab', []); const settled = bubble.querySelector('.assistant-stream-settled'); assert.ok(settled.querySelector('table'), 'the finished table should be a table'); assert.equal(settled.querySelectorAll('table tbody tr').length, 2); const tail = bubble.querySelector('.assistant-streaming-text'); assert.equal(tail.textContent.trim(), '| Half | tab'); assert.equal(tail.querySelector('table'), null, 'the unfinished row must not be parsed'); }); test('no raw-text
 dump, however long or table-heavy the answer', (t) => {
  const ctx = ui(t);
  const bubble = bubbleFor(ctx);
  // Both of the old bail-out conditions at once: over 3500 characters, and far
  // more than eight pipe rows. This used to render as one black 
.
  const long = 'Intro.\n\n' + (TABLE + '\n\n').repeat(60) + 'Trailing sen';
  assert.ok(long.length > 3500);
  ctx.renderStreamingInto(bubble, long, []);
  assert.ok(bubble.querySelectorAll('table').length >= 20);
  assert.equal(bubble.querySelector('pre'), null, 'nothing should fall back to a 
 dump');
  assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Trailing sen');
});

test('settled content is appended, never re-rendered', (t) => {
  const ctx = ui(t);
  const bubble = bubbleFor(ctx);
  ctx.renderStreamingInto(bubble, 'First.\n\nSecond stil', []);
  const settled = bubble.querySelector('.assistant-stream-settled');
  const firstNode = settled.firstChild;
  ctx.renderStreamingInto(bubble, 'First.\n\nSecond.\n\nThird stil', []);
  assert.equal(settled.firstChild, firstNode, 'the first block should be the same node, untouched');
  assert.equal(settled.children.length, 2);
  assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Third stil');
});

test('a diagram that has drawn is not torn down by the next block', async (t) => {
  const ctx = ui(t);
  const bubble = bubbleFor(ctx);
  let renders = 0;
  ctx.window.mermaid = {
    initialize() {},
    render(id, src) { renders++; return Promise.resolve({ svg: '' }); }
  };
  ctx.renderStreamingInto(bubble, '```mermaid\ngraph TD; A-->B;\n```\n\nNext par', []);
  const diagram = bubble.querySelector('[data-mermaid]');
  assert.ok(diagram, 'the diagram placeholder should be in the settled part');
  // Several more blocks arrive, each re-running renderEmbeddedBlocks.
  ctx.renderStreamingInto(bubble, '```mermaid\ngraph TD; A-->B;\n```\n\nNext paragraph.\n\nAnd another.\n\nStill go', []);
  await new Promise(resolve => setImmediate(resolve)); // ensureMermaid resolves on a microtask
  assert.equal(bubble.querySelector('[data-mermaid]'), diagram, 'same element');
  assert.equal(diagram.getAttribute('data-drawn'), '1');
  assert.equal(renders, 1, 'mermaid.render should be called once per diagram, not once per block');
});

test('a chart canvas is constructed once — a second Chart on it throws', (t) => {
  const ctx = ui(t);
  const bubble = bubbleFor(ctx);
  const built = [];
  ctx.window.Chart = function (c, cfg) { built.push(cfg); };
  const chart = '```chart\n{"type":"bar","data":{"labels":["a"],"datasets":[{"data":[1]}]}}\n```';
  ctx.renderStreamingInto(bubble, chart + '\n\nA para', []);
  ctx.renderStreamingInto(bubble, chart + '\n\nA paragraph.\n\nAnother.\n\nStill go', []);
  if (bubble.querySelector('canvas[data-chart]')) assert.equal(built.length, 1);
});

test('an empty stream shows the placeholder and holds no stale state', (t) => {
  const ctx = ui(t);
  const bubble = bubbleFor(ctx);
  ctx.renderStreamingInto(bubble, '', []);
  assert.match(bubble.innerHTML, /Generating answer/);
  assert.equal(bubble.assistantStreamState, null);
});

test('the final render drops the streaming state, so no frame appends into a detached node', (t) => {
  const ctx = ui(t);
  const bubble = bubbleFor(ctx);
  ctx.renderStreamingInto(bubble, 'First.\n\nSecond stil', []);
  assert.ok(bubble.assistantStreamState);
  ctx.fillMessageBubble(bubble, 'assistant', 'First.\n\nSecond, finished.', [], null, false);
  assert.equal(bubble.assistantStreamState, null);
  assert.equal(bubble.querySelector('.assistant-stream-settled'), null);
});

test('the tail is styled as prose, not left to the browser monospace default', (t) => {
  const css = read('public/css/assistant.css');
  assert.match(css, /\.assistant-streaming-text \{[^}]*white-space:pre-wrap/);
  assert.match(css, /\.assistant-streaming-text \{[^}]*color:var\(--g700\)/);
});