134 lines
21 KiB
JavaScript
134 lines
21 KiB
JavaScript
/**
|
||
* 已有资源接纳(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();
|
||
// 检查同一提交 cut 中的 depot 写入、文档、历史和 ledger;显示成功本身不足以证明这些状态一起持久保存。
|
||
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;
|
||
// 旧 Root 已没有 working/history/ACK/job 引用,手工清空共享缓存模拟这时发生 GC。
|
||
// 随后的 fresh 接纳属于更新的 Root,旧提交结束不能清掉新 Root 仍需落盘的待写字节。
|
||
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();}});
|