pediatric-ai-scribe-v3/test/generated-images-ui.test.js
Daniel 8a6a4df121 refactor: citations are a markdown-it token, and the numbers you see are display order
The old renderer rewrote the text: it found "[n]" with regexes, renumbered
them, and swapped the result back in — which broke inside `arr[2][1]`, inside
HTML attributes, and whenever two turns disagreed about what "[3]" meant. It
also had a fallback markdown renderer of its own for when the rewrite
produced something markdown-it would not parse.

Now "[n]" is an inline rule registered on the same markdown-it instance that
renders everything else. The parser decides what is prose and what is code, a
link, or a URL, so the rule never sees "[1]" inside a code span, and it steps
aside for "[1](url)". Math is two more rules on the same parser instead of a
regex pre-pass, so "$" inside a URL is no longer math.

Identity vs display: the stored "[n]" and each card's id are the source's
identity (sourceNumber) and are never rewritten. The number a reader sees is
the order of first appearance, computed at render time from the token stream
(orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat
re-opens pointing at the same cards it was saved with. Stored messages and
sources are untouched; export and the modal resolve by identity.

Translated HTML gets the same links through a TreeWalker over text nodes
(linkCitationsInHtml) rather than a regex over markup.

Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the
fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable),
renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert
token output; harnesses that render for real are given a parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 04:41:40 +02:00

149 lines
15 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { webcrypto, createHash } = require('node:crypto');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
const id = '12345678-1234-1234-1234-123456789abc';
const src = '/api/generated-images/' + id;
function client(options={}) {
const dom = new JSDOM('<div id="root"></div>',{url:'https://synthetic.test',runScripts:'outside-only'});
let owner='101'; const controller = new AbortController(); const calls=[]; const revoked=[];
dom.window.AccountBoundary={capture:()=>owner,valid:t=>!!owner&&t===owner,signal:()=>controller.signal};
dom.window.getAuthHeaders=()=>({Authorization:'Bearer synthetic-only'});
const context = { window:dom.window,document:dom.window.document,DOMException,AbortController,Blob,TextEncoder,crypto:webcrypto,Uint8Array,console,
setTimeout(){}, URL:{createObjectURL:()=> 'blob:synthetic',revokeObjectURL:u=>revoked.push(u)},
FileReader:class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes=>{this.result='data:'+blob.type+';base64,'+Buffer.from(bytes).toString('base64');this.onload();}); } },
fetch:async(url,init)=>{
calls.push({url,init});
if (options.fetch) return options.fetch(url,init);
if (url.includes('/jobs/')) return new Response(JSON.stringify({success:true,status:'done',jobId:id,imageUrl:src}),{headers:{'content-type':'application/json'}});
return new Response(png,{headers:{'content-type':'image/png','content-length':String(png.length),'x-image-owner':'101','x-image-sha256':createHash('sha256').update(png).digest('hex'),...options.headers}});
}
};
vm.createContext(context);vm.runInContext(fs.readFileSync('public/js/generatedImages.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),context);
vm.runInContext(fs.readFileSync('public/js/assistant/sharing.js','utf8').replace(/^export /gm,''),context);
return { context,dom,calls,revoked,becomeB(){owner='102';},leave(){owner=null;controller.abort();dom.window.dispatchEvent(new dom.window.Event('account-boundary'));} };
}
test('private load verifies auth/owner/MIME/length/checksum and blocks late account responses; transient URLs revoked',async()=>{
const ui=client();
const blob=await ui.context.privateImageBlob(src);assert.equal(blob.size,png.length);assert.equal(ui.calls[0].init.headers.Authorization,'Bearer synthetic-only');assert.equal(ui.calls[0].init.redirect,'error');
const img=ui.dom.window.document.createElement('img');ui.dom.window.document.body.append(img);await ui.context.hydrateImage(img,src);assert.equal(img.getAttribute('src'),'blob:synthetic');
const data=await ui.context.imageDataUrl(src);assert.equal(data,'data:image/png;base64,'+png.toString('base64'));
ui.leave();assert.ok(ui.revoked.includes('blob:synthetic'));assert.equal(img.getAttribute('src'),null);await assert.rejects(ui.context.privateImageBlob(src),/Verified account/);ui.dom.window.close();
for (const headers of [{'x-image-owner':'102'},{'content-type':'text/html'},{'content-length':'1'},{'x-image-sha256':'0'.repeat(64)}]) {
const bad=client({headers});await assert.rejects(bad.context.privateImageBlob(src));bad.dom.window.close();
}
let finish;const late=client({fetch:()=>new Promise(r=>finish=r)});const pending=late.context.privateImageBlob(src);late.leave();finish(new Response(png));await assert.rejects(pending,e=>e.name==='AbortError');late.dom.window.close();
});
test('inline image jobs append DOM without altering answer/citation nodes; reopened jobs use stable reference',async()=>{
const ui=client();const root=ui.dom.window.document.getElementById('root');root.innerHTML='<p>Exact body <a href="#source-3">[3]</a></p>';
const original=root.firstChild;const html=original.outerHTML;
ui.context.renderImageJobs(root,[{jobId:id}],'clinical_assistant',(card,data)=>{const img=ui.dom.window.document.createElement('img');img.setAttribute('src',data.imageUrl);card.append(img);});
await new Promise(r=>setImmediate(r));assert.equal(root.firstChild,original);assert.equal(original.outerHTML,html);assert.equal(root.querySelector('img').getAttribute('src'),src);ui.dom.window.close();
});
test('clinical interception is removed and export path embeds verified images rather than rewriting answer text',()=>{
const source=fs.readFileSync('public/js/clinicalAssistant.js','utf8');assert.doesNotMatch(source,/if \(isImageRequest|prepareSidebarImagePrompt/);assert.match(source,/attachImageJobs\(loading/);
const exporter=fs.readFileSync('public/js/assistant/export.js','utf8');assert.match(exporter,/images.push\(await imageDataUrl/);assert.match(exporter,/messages.push\(\{ \.\.\.message, images \}\)/);
});
test('native download cannot fall through to web share after an account transition', async () => {
const ui = client(); let shares = 0;
ui.context.navigator = { canShare: () => true, share: async () => { shares++; }, userAgent: 'synthetic' };
ui.context.File = File;
ui.dom.window.NativeFiles = { saveImage() { ui.leave(); return 'error:synthetic native cancellation'; } };
vm.runInContext(fs.readFileSync('public/js/assistant/images.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
await assert.rejects(ui.context.downloadFromServer(src, ui.context.captureSharingOwner()), e => e.name === 'AbortError');
assert.equal(shares, 0); ui.dom.window.close();
});
test('actual assistant export embeds owned bytes, keeps source/page/body identity and closes on account transition', async () => {
const ui = client(); const rendered = [];
ui.dom.window.matchMedia = () => ({ matches: true });
ui.context.escapeHtml = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;');
ui.context.escapeAttr = ui.context.escapeHtml;
vm.runInContext(fs.readFileSync('public/js/assistant/export.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
const body = ' Body [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
const sources = [{ number: 3, title: 'Synthetic three', page: 19 }, { number: 1, title: 'Synthetic one', page: 4 }];
const state = { lastAnswer: body, lastSources: sources, messages: [{ role: 'user', content: 'Diagram' }, { role: 'assistant', content: body, sources, imageJobs: [{ jobId: id }] }] };
const original = JSON.stringify(state);
const exporter = ui.context.createAssistantExporter({ renderMarkdown(text, refs) { rendered.push({ text, refs }); return '<p>Rendered [3, 1].</p>'; } });
await exporter.exportAnswerPdf(state);
assert.equal(JSON.stringify(state), original); assert.equal(rendered[0].text, body); assert.deepEqual(rendered[0].refs, sources);
const modal = ui.dom.window.document.getElementById('assistant-export-modal');
assert.ok(modal); assert.equal(modal.querySelector('img').getAttribute('src'), 'data:image/png;base64,' + png.toString('base64'));
assert.match(modal.textContent, /\[3\] Synthetic three, page 19/); assert.match(modal.textContent, /\[1\] Synthetic one, page 4/);
ui.leave(); assert.equal(ui.dom.window.document.getElementById('assistant-export-modal'), null); ui.dom.window.close();
});
test('image-specific omitted turns and exact UTF16 use are visible without touching normal transcript', async () => {
const metadata={includedTurns:2,totalTurns:7,used:32000,limit:32000,unit:'UTF-16 code units'};
const ui=client({fetch:async()=>new Response(JSON.stringify({success:true,status:'pending',jobId:id,context:metadata}))});
const root=ui.dom.window.document.getElementById('root'); root.innerHTML='<p>Original [3, 1].</p>';const original=root.firstChild;
ui.context.renderImageJobs(root,[{jobId:id}],'clinical_assistant',()=>{}); await new Promise(r=>setImmediate(r));
assert.doesNotMatch(root.textContent,/preceding turns included|Older turns omitted/,'context metadata is no longer shown'); assert.match(root.textContent,/Image: (done|generating|checking|pending)/);
assert.equal(root.firstChild,original); ui.dom.window.close();
});
test('legacy HTTP/data and Share-only fallbacks keep the ORIGINAL owner across conversion and cancellation', async () => {
for (const mode of ['native-http-share-only','native-data-filesystem','web-http','browser-http','native-recapture','filesystem-late','native-conversion']) {
const ui=client(); let shares=0,writes=0,saves=0,anchors=0;
ui.context.File=File; ui.context.atob=atob; ui.context.escapeAttr=String;
ui.context.navigator={userAgent:'synthetic',canShare:()=>true,...(mode==='web-http'?{share:async()=>{shares++;}}:{})};
const source=mode.includes('data')?'data:image/png;base64,'+png.toString('base64'):'https://synthetic.test/legacy.png';
if(mode.startsWith('native') || mode==='filesystem-late') {
ui.dom.window.Capacitor={isNativePlatform:()=>true,Plugins:{Share:{share:async()=>{shares++;}},...((mode.includes('filesystem') || mode==='native-recapture')?{Filesystem:{writeFile:async()=>{writes++;if(mode==='filesystem-late'){ui.leave();ui.becomeB();}return {uri:'cache:test'};}}}:{})}};
if(mode!=='filesystem-late') ui.dom.window.NativeFiles={saveImage(){saves++;ui.leave();if(mode==='native-recapture')ui.becomeB();return 'error:capability failure';}};
} else {
ui.context.fetch=async()=>({ok:true,blob:async()=>{ui.leave();return new Blob([png],{type:'image/png'});}});
}
if(mode==='native-conversion') ui.context.FileReader=class { readAsDataURL(){queueMicrotask(()=>{ui.leave();ui.becomeB();this.result='data:image/png;base64,'+png.toString('base64');this.onload();});} };
ui.dom.window.HTMLAnchorElement.prototype.click=()=>{anchors++;};
vm.runInContext(fs.readFileSync('public/js/assistant/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
const store=ui.context.createAssistantImageStore(); store.renderGeneratedImage(source); await store.downloadImage('img-1');
assert.equal(shares,0,mode);assert.equal(writes,mode==='filesystem-late'?1:0,mode);assert.equal(anchors,0,mode);
if(mode.startsWith('native')) assert.equal(saves,mode==='native-conversion'?0:1);ui.dom.window.close();
}
});
test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, without removing A from its conversation turn', async () => {
const b='22345678-1234-1234-1234-123456789abc';let status='pending',saved;
const ui=client();ui.dom.window.document.body.innerHTML=fs.readFileSync('public/components/assistant.html','utf8');
Object.assign(ui.context,{escapeAttr:String,escapeHtml:String,EMPTY_PROMPT_SETS:[],orderSourcesByCitation: (text, sources) => ({ text: text, sources: sources || [] }), renderAssistantMarkdown:text=>'<p>'+text+'</p>',renderSourcesList:()=>'',
createAssistantExporter:()=>({invalidate(){}}),createAssistantImageStore:()=>({renderGeneratedImage:url=>'<img src="'+url+'">',clear(){}}),
startAssistantImageJob:async()=>({success:true,jobId:b,status:'pending'}),
saveAssistantChat:async payload=>{saved=JSON.parse(JSON.stringify(payload));return {success:true};},fetchSavedAssistantChats:async()=>({success:true,chats:[]})});
ui.context.imageJson=async url=>{const jobId=url.split('/').at(-1);return {success:true,jobId,status:jobId===id?'done':status,imageUrl:'/api/generated-images/'+jobId};};
vm.runInContext(fs.readFileSync('public/js/clinicalAssistant.js','utf8').replace(/^import[\s\S]*?;\n/gm,''),ui.context);
const transcript=[{role:'assistant',content:'Original [3, 1].',sources:[{number:3,page:19}],imageJobs:[{jobId:id}]}];
ui.context.restoreSavedChat({messages:transcript,lastAnswer:'Original [3, 1].',generatedImage:src});
ui.context.startImageFromSelection('B request','');await new Promise(r=>setImmediate(r));await new Promise(r=>setImmediate(r));ui.context.performAutosave();
assert.equal(saved.generatedImage, undefined, 'an empty sidebar reference is omitted while a job is queued');assert.equal(saved.generatedImageJobs[0].jobId,b);assert.equal(saved.messages[0].imageJobs[0].jobId,id);
ui.context.restoreSavedChat(saved);await new Promise(r=>setImmediate(r));assert.equal(ui.context.lastGeneratedImageSrc,'');
vm.runInContext(fs.readFileSync('public/js/assistant/export.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
ui.context.imageDataUrl=async url=>'data-for:'+url;
const state=()=>({messages:ui.context.messages,lastGeneratedImageSrc:ui.context.lastGeneratedImageSrc,generatedImageJobs:ui.context.generatedImageJobs});
const exportOwner=ui.context.captureSharingOwner();
await assert.rejects(ui.context.preparePrivateExport(state(),exportOwner),/not complete/);
// Also repairs already-saved inconsistent A URL/B ID from the previous implementation.
await assert.rejects(ui.context.preparePrivateExport({...state(),lastGeneratedImageSrc:src},exportOwner),/not complete/);
status='done';ui.context.restoreSavedChat({...saved,generatedImage:'/api/generated-images/'+b});await new Promise(r=>setImmediate(r));
assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b,'the completed B asset is the chat reference in the gallery world');
const exported=await ui.context.preparePrivateExport(state(),exportOwner);assert.equal(exported.lastGeneratedImageSrc,'data-for:/api/generated-images/'+b);
assert.equal(exported.messages[0].images[0],'data-for:'+src);assert.equal(exported.messages[0].content,transcript[0].content);
ui.context.performAutosave();assert.equal(saved.generatedImage,'/api/generated-images/'+b,'the completed sidebar image persists with the chat');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close();
});
test('same-owner legacy sharing still supports NativeFiles, Filesystem, Share-only, Web Share and browser', async () => {
for(const mode of ['native','filesystem','share-only','web','browser']) {
const ui=client();let effects=0;
ui.context.escapeAttr=String;ui.context.File=File;ui.context.atob=atob;ui.context.navigator={userAgent:'synthetic'};
ui.context.fetch=async()=>new Response(png);
if(['native','filesystem','share-only'].includes(mode)) {
ui.dom.window.Capacitor={isNativePlatform:()=>true,Plugins:{Share:{share:async()=>{effects++;}}}};
if(mode==='native')ui.dom.window.NativeFiles={saveImage(){effects++;return 'saved:test';}};
if(mode==='filesystem')ui.dom.window.Capacitor.Plugins.Filesystem={writeFile:async()=>({uri:'cache:test'})};
}
if(mode==='web')ui.context.navigator={...ui.context.navigator,canShare:()=>true,share:async()=>{effects++;}};
ui.dom.window.HTMLAnchorElement.prototype.click=()=>{effects++;};
vm.runInContext(fs.readFileSync('public/js/assistant/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
const store=ui.context.createAssistantImageStore();store.renderGeneratedImage('https://synthetic.test/legacy.png');await store.downloadImage('img-1');
assert.equal(effects,1,mode);ui.dom.window.close();
}
});