Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck the model fills in, rendered by python-pptx with fit-to-slide text, figures, a vision review and themes, against Learning Hub's markdown-through-pandoc — and the articles and quizzes now live in the quiz app. Keeping a second, weaker generator and a whole CMS beside it was not earning its maintenance. Removed: three routers, the Learning Hub and Content Manager tabs, their components and frontend modules, the five database tables, the WebDAV browser, the content embedding column and its vector index. Content was exported first — every article as markdown plus a full SQL dump of all five tables — to ops-backups/learning-hub-export-*. That export is the restore path; the migration's down() can recreate the shape but never the rows, and says so. Two things this simplifies rather than merely deletes: generated_image_links existed only to record which published content an image appeared in, and it was the sole reason a generated image could be read by someone who did not make it. Images are now owner-only — the visibility rule is one WHERE clause instead of a join across two tables and a published flag. embeddings.js keeps the model discovery the admin panel uses and loses searchSimilar and generateContentEmbedding, which queried a table that no longer exists. Kept deliberately: Nextcloud connect, disconnect and export, which are how a generated note reaches a real filesystem and have nothing to do with Learning Hub; learningRetrieval, which despite its name is the clinical corpus search My Resources depends on; and the pandoc reference deck, still the fallback when the python renderer fails, moved from assets/learning to assets/deck now that the old name misleads. Tests: four Learning-Hub-only files removed, and the individual cases inside shared files that asserted its behaviour. Where a test used a Learning endpoint only as a convenient example — the account-boundary token test, the policy matrix — it now uses one that still exists, so the property it proves is unchanged. Co-Authored-By: Claude Opus 5 <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:[],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();
|
|
}
|
|
});
|
|
|