pediatric-ai-scribe-v3/test/generated-images-ui.test.js

212 lines
20 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('actual bundled Tiptap persists/reopens stable generated image attributes without modifying educational text',async()=>{
const dom=new JSDOM('<div id="editor"></div>',{url:'https://synthetic.test',runScripts:'outside-only'});
dom.window.eval(fs.readFileSync('public/vendor/tiptap.bundle.js','utf8'));
const context={document:dom.window.document,queueMicrotask(){},hydrateImage(){},revokeImageUrl(){}};
vm.createContext(context);
const source=fs.readFileSync('public/js/learningHub/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,'');
vm.runInContext(source,context);
const T=dom.window.Tiptap;const extension=context.generatedImageExtension(T);
const editor=new T.Editor({element:dom.window.document.getElementById('editor'),extensions:[T.StarterKit,extension],content:'<p>Exact <strong>teaching</strong> body.</p>'});
const before=editor.getHTML();editor.commands.insertContentAt(editor.state.doc.content.size,{type:'generatedImage',attrs:{src,alt:'Generated teaching visual'}});
const saved=editor.getHTML();assert.ok(saved.startsWith(before));assert.ok(saved.includes(src));assert.ok(!saved.includes('blob:'));
editor.commands.setContent(saved);assert.equal(editor.getHTML(),saved);editor.destroy();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.match(root.textContent,/2\/7 preceding turns included; 32000\/32000 UTF-16 code units/);
assert.match(root.textContent,/Older turns omitted from image input only/); 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:[],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.generateImage('B request');await new Promise(r=>setImmediate(r));await ui.context.saveCurrentChat();
assert.equal(saved.generatedImage, undefined, 'offbox image is session-only and never saved');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:src});await new Promise(r=>setImmediate(r));
assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b);
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);
await ui.context.saveCurrentChat();assert.equal(saved.generatedImage,undefined,'offbox image stays session-only even after job completes');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close();
});
test('actual CMS presentation image controls are visible and insertion/save/reopen preserve Markdown; preserved refinement never resets editor', async () => {
const ui=client();ui.dom.window.document.body.innerHTML='<style>.hidden{display:none!important}</style>'+fs.readFileSync('public/components/cms.html','utf8');
ui.dom.window.HTMLElement.prototype.scrollIntoView=()=>{};
let saved,resets=0,html='<p>Original [3].</p>';
const editor={isDestroyed:false,getHTML:()=>html,commands:{setContent(value){resets++;html=value;},clearContent(){html='';}}};
Object.assign(ui.context,{getTpHTML:e=>e.getHTML(),makeTpEditor:()=>editor,clearQuestionBlocks:el=>el.replaceChildren(),showToast(){},showLoading(){},hideLoading(){},showBusy(){},hideBusy(){},getSelectedModel:()=>'',
createSlideController:()=>({}),createCmsController:()=>({refreshAll(){}}),createQuizController:()=>({}),createWebdavController:()=>({}),createAiPanelController:()=>({}),
getJson:async()=>({success:true,content:{...saved,id:1,questions:[]}}),
sendJson:async(url,method,payload)=>{if(url.endsWith('ai-refine'))return {success:true,bodyPreserved:true,refined:'SHOULD NOT RESET',imageJobs:[]};saved=payload;return {success:true,id:1};}
});
vm.runInContext(fs.readFileSync('public/js/learningHub/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
vm.runInContext(fs.readFileSync('public/js/learningHub.js','utf8').replace(/^import[\s\S]*?;\n/gm,''),ui.context);
ui.context.openEditor(null,'presentation');const doc=ui.dom.window.document;
const panel=doc.querySelector('[aria-label="Learning Hub images"]');assert.ok(panel);
for(let el=panel;el;el=el.parentElement) assert.notEqual(ui.dom.window.getComputedStyle(el).display,'none',el.id);
const markdown='---\nmarp: true\n---\n# Original [3]\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
doc.getElementById('lh-marp-editor').value=markdown;doc.getElementById('lh-cms-edit-title').value='Presentation';
ui.context.learningImages.show([{jobId:id}]);await new Promise(r=>setImmediate(r));
const insert=[...panel.querySelectorAll('button')].find(b=>b.textContent==='Insert image at end of content');assert.ok(insert);insert.click();
const inserted=doc.getElementById('lh-marp-editor').value;assert.ok(inserted.startsWith(markdown));assert.ok(inserted.includes(']('+src+')'));
ui.context.saveContent();await new Promise(r=>setImmediate(r));assert.equal(saved.body,inserted);assert.equal(saved.content_type,'presentation');
ui.context.openEditor(1,'presentation');await new Promise(r=>setImmediate(r));assert.equal(doc.getElementById('lh-marp-editor').value,inserted);
// Same real frontend refine handler: no setContent on bodyPreserved.
html='<p>Original [3].</p>';doc.getElementById('lh-ai-refine-input').value='Add an image';const before=resets;
ui.context.submitRefineBody();await new Promise(r=>setImmediate(r));assert.equal(resets,before);assert.equal(html,'<p>Original [3].</p>');
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();
}
});
test('Learning private export keeps the initial owner between images and has no late UI or download after same-ID replacement', async () => {
const ui = client(); let downloads = 0, conversions = 0;
ui.dom.window.document.body.innerHTML = '<div id="lh-images-section"></div>';
ui.context.sanitizeHtml = value => value;
ui.dom.window.HTMLAnchorElement.prototype.click = () => { downloads++; };
vm.runInContext(fs.readFileSync('public/js/learningHub/images.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
const images = ui.context.createLearningImages(() => ({ getHTML: () => '<p>Exact body [1].</p><img src="' + src + '"><img src="' + src + '">' }), () => null);
images.mount();
const original = ui.context.imageDataUrl;
ui.context.imageDataUrl = async (...args) => {
if (++conversions === 1) {
ui.dom.window.AccountBoundary = { ...ui.dom.window.AccountBoundary };
return 'data:image/png;base64,' + png.toString('base64');
}
return original(...args);
};
const status = ui.dom.window.document.querySelector('[role=status]'); const before = status.textContent;
await ui.dom.window.document.querySelector('[data-export]').onclick();
assert.equal(conversions, 2, 'exercise the continuation between images');
assert.equal(ui.calls.length, 0, 'do not recapture same-ID B to fetch another image');
assert.equal(downloads, 0); assert.equal(status.textContent, before);
ui.dom.window.close();
});