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
149 lines
15 KiB
JavaScript
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, '&').replace(/</g, '<');
|
|
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();
|
|
}
|
|
});
|
|
|