Files
Endoleap/tests/headless/asset-adoption.test.mjs

132 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 已有资源接纳(adoption)与文档图片导入的引擎测试。复用原始字节仍需重新授权,不生成多余上传任务。
* 绑定、文档、history、ledger 和导入进度通过一致状态根发布,资源字节及引用通过相应持久化 cut 保存。
* 内存端口可控制授权、配额和提交时机;真实 IndexedDB 的事务/回收竞态由 browser-cases 另测。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents,createDocument,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
// 共享内置定义及账户/工作区作用域;authority 标识原始资源的授权来源。
const registry=createRegistry(builtinComponents()),authority='https://source.test/w/assets',accountId='account',workspaceId='workspace';
// 计算真实 Blob 的 SHA-256;此处 MIME 为测试元数据,不试图解码文本字节为图片像素。
const bytes=new Blob(['original bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
// 不可变资源引用同时包含 authority、digest 和服务端资源元数据,不能只以 URL 代表身份。
const ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,bytes:bytes.size,width:2,height:1,mime_type:'image/png'}};
// 使用接纳句柄创建 image 元素;assetId 保存资源身份,src 使用经授权的来源。
const node=(handle,id='picture',owner='canvas')=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}});
// 为各项目维护独立文档/回执/leader,同时在同账户工作区内共享 depot 字节;开关可阻塞授权或模拟配额失败。
function fixture(){
const sourceFiles=new Map(),roots=new Map(),cache=new Map(),leaders=new Map(),servers=new Map(),calls=[],batches=[],errors=[];let now=0,next=0,allowed=true,gate,fail=false,closed=false;
// 共享缓存键含账户、工作区及 authority+assetId;项目 ID 不进入键,便于跨项目复用。
const key=(leader,id)=>JSON.stringify([leader.scope.accountId,leader.scope.workspaceId,id]);
// 最小存储端口:校验活动 leader,保存文件、image 及 depot writes;cache collect 故意不报告完整扫描。
const storage={assetDepot:{
// depot 读取只接受活动 leader,并按账户/工作区/authority/资源身份查询共享缓存。
async read(leader,auth,id){assert.equal(leader.active,true);return cache.get(key(leader,auth+id));},
// 此内存 fixture 不证明完整 GC 覆盖,明确返回 blockedProjects 以避免假装可删除。
async collect(){return{complete:false,blockedProjects:['fixture']};}},
// 每个项目只允许一个 leader;release 将其标记失效并从占用表删除。
async claim(scope){assert.equal(leaders.has(scope.projectId),false);const leader={scope,fence:1,active:true,async release(){leader.active=false;leaders.delete(scope.projectId);}};leaders.set(scope.projectId,leader);return leader;},
// 从项目 roots 表克隆上次 image,缺失时返回 null。
async recover(leader){return structuredClone(roots.get(leader.scope.projectId)??null);},
// 测试用项目源文件表;与 depot 的跨项目字节缓存分开。
async readFile(_leader,id){return sourceFiles.get(id);},
// 配额故障在任何写之前抛出;正常写入项目文件、共享字节及克隆 image,并记录提交批次供断言。
async commit(leader,batch){if(fail)throw Error('quota');assert.equal(leader.active,true);for(const file of batch.files??[])sourceFiles.set(file.id,file.bytes);const root=structuredClone(batch.image);
// 只有 ref 更新而未提供新字节时沿用旧字节;真实事务回滚能力由浏览器用例覆盖。
for(const write of batch.depot?.writes??[]){const k=key(leader,write.ref.authority+write.ref.asset.id),previous=cache.get(k);cache.set(k,{ref:write.ref,bytes:write.bytes??previous?.bytes});}roots.set(leader.scope.projectId,root);batches.push(batch);return{durableRootRevision:root.rootRevision};},
// 记录 storage 关闭,供 WorkspaceRuntime 关闭顺序用例检查。
close(){closed=true;}};
// 授权必须每次执行,字节读取可由共享缓存避免;上传相关方法一旦被误调用就报错。
const assets={authority:{scope:authority,
// 先记录授权尝试并等待可控 gate,再检查权限和 AbortSignal;返回当前资源 ID 对应的规范引用。
async resolve(id,signal){calls.push('authorize');if(gate)await gate;if(!allowed)throw Error('denied');signal.throwIfAborted();return{...ref,asset:{...ref.asset,id,url:`${authority}/${id}`}};},
// 原始字节仅在缓存未命中时读取,计数用于检测重复下载。
async read(){calls.push('read');return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/remote/${id}`,
// 接纳既有资源不应创建上传;此断言端口一旦被调用就使测试失败。
async upload(){throw Error('adoption uploaded bytes');},
// 接纳也不应查询上传回执,否则说明错误进入上传状态机。
async query(){throw Error('adoption queried upload');},
// 接纳使用 authority 的资源证明,不能把它误当上传 accept 回执处理。
accept(){throw Error('adoption accepted upload');}};
// 每个项目的假服务各自保存文档和递增版本;提交记录 canonical 回执,供本地成功接纳使用。
function adapters(projectId){if(!servers.has(projectId))servers.set(projectId,{document:createDocument(projectId,[{id:'canvas',parent:null}],[]),version:1,receipts:new Map()});const server=servers.get(projectId);return{assets,persistence:{compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){calls.push('submit');const intent=JSON.parse(request.body).intent;server.document=applyChanges(server.document,intent.changes);server.receipts.set(request.id,{document:server.document,versions:new Map([['canvas',++server.version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>server.receipts.get(request.id)}};}
// 暴露时间、授权、持久化和挂起开关;open 可复用上次 image,但不复用旧引擎实例。
return{advance(){now+=10000;},storage,roots,cache,errors,batches,calls,adapters,servers,get closed(){return closed;},deny(){allowed=false;},gate(value){gate=value;},fail(value){fail=value;},open(projectId='p',extra={}){return openProjectEngine({scope:{accountId,workspaceId,projectId},registry,document:createDocument(projectId,[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage,...adapters(projectId),clock:{now:()=>now,setTimer:()=>0,clearTimer(){}},createId:()=>`id-${++next}`,onError:e=>errors.push(e),...extra});}};
}
// 把接纳和创建图片放在同一个同步事务内,确保不会只留下孤立 binding。
const adopt=(engine,id='picture')=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:node(handle,id)}));
// 一次接纳同时发布绑定及历史/账本,保存共享字节而不生成上传;重开可显示原字节,undo/redo 递增元素身份。
test('adoption atomically publishes binding/history/ledger and reopens shared bytes without an upload',async()=>{
const f=fixture();let engine=await f.open();try{
assert.equal((await adopt(engine)).status,'applied');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings.get('shared').digest,digest);await engine.settle();
const image=f.roots.get('p');assert.equal(image.formatVersion,13);decodeRecovery(image,registry);const cut=f.batches.find(batch=>batch.image.payload.runtime.assetBindings);assert.equal(cut.depot.writes[0].bytes,bytes);assert.equal(cut.files.length,0);assert.equal(cut.image.payload.document.elements.length,1);assert.equal(cut.image.payload.history.undo.length,1);assert.equal(cut.image.payload.runtime.ledger.operations.some(([,row])=>row.intent.operationType==='asset.upload'),false);
await engine.close();engine=await f.open();const handle=await engine.displayResource('shared');assert.equal(await(await fetch(handle.url)).text(),'original bytes');handle.release();
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(f.calls.filter(v=>v==='read').length,1);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// 第二个项目复用共享字节但再次授权;权限撤销后即使缓存仍在,也不能修改状态根。
test('a second project uses shared bytes but must obtain fresh permission before creating a binding',async()=>{
const f=fixture(),p=await f.open('p');await adopt(p);await p.settle();await p.close();let q=await f.open('q');try{
assert.equal((await adopt(q)).status,'applied');await q.settle();assert.equal(f.calls.filter(v=>v==='authorize').length,2);assert.equal(f.calls.filter(v=>v==='read').length,1);await q.close();q=await f.open('q');const display=await q.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();
f.deny();const before=q.getSnapshot();await assert.rejects(adopt(q,'denied'),/denied/);assert.strictEqual(q.getSnapshot(),before);
}finally{await q.close();}
});
// 重复 ID 导致事务拒绝、异步事务或空事务都不能留下半份 binding/history,后续落盘也不应写缓存。
test('rejected and asynchronous transactions cannot leave a binding or history behind',async()=>{
const f=fixture(),engine=await f.open();try{
const before=engine.getSnapshot();const rejected=await engine.adoptResource('shared',(handle,tx)=>{tx.dispatch({type:'element.create',element:node(handle)});tx.dispatch({type:'element.create',element:node(handle)});});assert.equal(rejected.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
const asynchronous=await engine.adoptResource('shared',async(handle,tx)=>{tx.dispatch({type:'element.create',element:node(handle)});});assert.equal(asynchronous.code,'async_transaction');assert.strictEqual(engine.getSnapshot(),before);
assert.equal((await engine.adoptResource('shared',()=>{})).operationIds.length,0);assert.strictEqual(engine.getSnapshot(),before);await engine.settle();assert.equal(f.cache.size,0);
}finally{await engine.close();}
});
// binding 只能由接纳路径管理;格式降级、操作/资源身份不符和伪造 digest 无法通过恢复,换 authority 也不能解释旧绑定。
test('binding mutation is owned and recovery rejects downgraded, mismatched or forged bindings',async()=>{
const f=fixture(),engine=await f.open();try{await adopt(engine);await engine.settle();assert.equal(engine.editor.updateRuntime(previous=>({...previous,assetBindings:new Map()})).code,'asset_bindings_owned');
for(const change of [image=>image.formatVersion=12,image=>image.payload.runtime.assetBindings[0][1].operationId='missing',image=>image.payload.runtime.assetBindings[0][1].asset.id='other',image=>image.payload.runtime.assetBindings[0][1].digest='bad']){const image=structuredClone(f.roots.get('p'));change(image);assert.throws(()=>decodeRecovery(image,registry));}
await engine.close();await assert.rejects(f.open('p',{assets:{...f.adapters('p').assets,authority:{...f.adapters('p').assets.authority,scope:'other'}}}),{code:'asset_binding_interpreter_unavailable'});
}finally{await engine.close();}
});
// 配额失败保留本地可见图片,但阻止文档网络派发;修复后先使原子 cut 持久化,再允许发送一次文档请求。
test('quota failure preserves the visible adopted image and blocks document dispatch until its atomic cut is durable',async()=>{
const f=fixture(),engine=await f.open();try{await engine.settle();f.fail(true);assert.equal((await adopt(engine)).status,'applied');await assert.rejects(engine.settle(),/quota/);assert.equal(f.calls.includes('submit'),false);assert.equal(f.cache.size,0);const display=await engine.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();f.fail(false);await engine.retry();await engine.settle();f.advance();await engine.retry();await engine.settle();assert.equal(f.calls.filter(v=>v==='submit').length,1);assert.equal(f.cache.size,1);}finally{f.fail(false);await engine.close();}
});
// 授权仍挂起时关闭引擎,迟到授权结果不能发布内容、绑定或共享缓存,关闭过程仍能完成。
test('scope retirement and close fence late adoption, retaining no half binding',async()=>{
const f=fixture();let done;f.gate(new Promise(resolve=>done=resolve));const engine=await f.open(),pending=adopt(engine);await Promise.resolve();await Promise.resolve();const closing=engine.close();done();await assert.rejects(pending);await closing;assert.equal(f.roots.get('p').payload.document.elements.length,0);assert.equal(f.cache.size,0);
});
// WorkspaceRuntime 为多个项目提供同一个 depot;关闭项目后再关闭共享资源和存储,不能冻结调用者 storage 对象。
test('WorkspaceRuntime owns one depot across projects and closes it after projects',async()=>{
const f=fixture(),runtime=createWorkspaceRuntime({accountId,workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:id=>f.adapters(id)});
assert.deepEqual(runtime.assetDepot.scope,{accountId,workspaceId});assert.equal(Object.isFrozen(f.storage),false);
const p=await runtime.open(createDocument('p',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(p);await p.settle();const q=await runtime.open(createDocument('q',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(q);await q.settle();assert.equal(runtime.assetDepot.getResources().size,1);assert.equal(f.calls.filter(v=>v==='read').length,1);await runtime.close();assert.equal(f.closed,true);assert.equal(runtime.assetDepot.getResources().size,0);
});
// 旧的无引用状态根正在落盘时清除字节,再接纳新图片;新字节必须留到匹配的新 cut 持久化,不能被旧提交释放。
test('an older Root cannot release newly adopted bytes while its unpinned manifest is committing',async()=>{
const f=fixture(),engine=await f.open('p',{historyLimit:1});let release,started;const commit=f.storage.commit;
try{
await adopt(engine);await engine.settle();engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});await engine.settle();engine.editor.dispatch({type:'element.create',element:{...node({id:'unused',src:'unused'},'text'),componentId:'text',data:{title:'text',text:''}}});await engine.settle();
const ready=new Promise(resolve=>started=resolve),gate=new Promise(resolve=>release=resolve);let once=true;
f.storage.commit=async(leader,batch)=>{if(once){once=false;assert.equal(batch.depot.pins.some(([id])=>id==='shared'),false);started();await gate;}return commit(leader,batch);};
engine.editor.updateRuntime(previous=>({...previous}));await ready;
// GC is allowed here: the old Root has no working/history/ACK/job reference.
f.cache.clear();assert.equal((await adopt(engine,'fresh')).status,'applied');release();await engine.settle();assert.equal(await f.cache.values().next().value.bytes.text(),'original bytes');assert.equal(f.roots.get('p').payload.document.elements.some(element=>element.id==='fresh'),true);
}finally{release?.();f.storage.commit=commit;await engine.close();}
});
// 生成含图片引用的导入文件;其 src 故意不可信,导入应改用 authority 的经授权原始 URL。
const exportedImages=(ids=['shared'])=>new Blob([JSON.stringify({documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:ids.map((id,index)=>node({id,src:'https://untrusted.test/ignored'},'imported-'+index,'source')),tombstones:{}})]);
// 文档导入授权原字节,并以同一状态根发布绑定、内容和 committedSourceKeys;不上传,重开后仍可显示。
test('document image import authorizes original bytes and atomically publishes binding, content and progress without upload, then reopens',async()=>{const f=fixture();let engine=await f.open();try{const observed=[],stop=engine.editor.subscribe((_,root)=>{if(root.document.elements.size){const job=[...root.runtime.imports.values()][0];observed.push(root);assert.ok(root.runtime.assetBindings.get('shared'));assert.equal(job.committedSourceKeys.length,root.document.elements.size);}}),id=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();stop();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.ok(observed.length);const image=[...engine.getSnapshot().document.elements.values()][0];assert.equal(image.data.src,ref.asset.url);assert.equal(image.data.assetId,'shared');assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(f.calls.filter(x=>x==='authorize').length,1);assert.equal(f.roots.get('p').formatVersion,17);decodeRecovery(f.roots.get('p'),registry);await engine.close();engine=await f.open();const handle=await engine.displayResource('shared');assert.equal(await(await fetch(handle.url)).text(),'original bytes');handle.release();assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}});
// 取消挂起的授权后迟到结果不能发布;后续权限拒绝的导入保留 partial 失败状态,不能生成 binding。
test('document image authorization cannot publish after cancel and denied imports retain explicit failure',async()=>{const f=fixture(),engine=await f.open();let release;try{f.gate(new Promise(resolve=>release=resolve));const id=engine.imports.startDocument(exportedImages(),'canvas');for(let i=0;i<100&&!f.calls.includes('authorize');i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(f.calls.includes('authorize'));assert.equal(engine.getSnapshot().document.elements.size,0);await engine.imports.cancel(id);release();f.gate();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);f.deny();const denied=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(denied).status,'partial');assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);}finally{release?.();await engine.close();}});
// 同一文档批次接纳两个资源时,绑定和元素数量一致,完成记录可由恢复 codec 解码。
test('one document batch can adopt multiple immutable resources with one consistent Root',async()=>{const f=fixture(),engine=await f.open();try{const id=engine.imports.startDocument(exportedImages(['shared','second']),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().runtime.assetBindings.size,2);assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0);decodeRecovery(f.roots.get('p'),registry);}finally{await engine.close();}});
// 非法图片几何使导入事务拒绝,即使已经授权也不能留下 binding、内容、缓存字节或 depot write。
test('rejected document image transaction never leaves an adopted binding or durable byte write',async()=>{const f=fixture(),engine=await f.open();try{const input=JSON.parse(await exportedImages().text());input.elements[0].geometry.width=-1;const id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);assert.equal(f.cache.size,0);assert.ok(!f.batches.some(batch=>batch.depot?.writes.length));}finally{await engine.close();}});
// 第一次导入已缓存字节,第二份文档仍重新检查权限;拒绝后只保留第一份已发布内容。
test('resource permission is checked again for another document even after its bytes are cached',async()=>{const f=fixture(),engine=await f.open();try{const first=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(first).status,'completed');f.deny();const second=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(second).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.calls.filter(x=>x==='authorize').length>=2,true);}finally{await engine.close();}});