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('
',{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='Exact body [3]
'; 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('',{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:'Exact teaching body.
'}); 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, '&').replace(/Rendered [3, 1].'; } }); 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='Original [3, 1].
';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:[],renderAssistantMarkdown:text=>''+text+'
',renderSourcesList:()=>'', createAssistantExporter:()=>({invalidate(){}}),createAssistantImageStore:()=>({renderGeneratedImage:url=>'Original [3].
'; 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)); console.log('CMSPANEL', panel.innerHTML.slice(-600)); 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='Original [3].
';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,'Original [3].
'); 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 = ''; 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: () => 'Exact body [1].