Files
Endoleap/tests/headless/asset-depot-browser-cases.mjs

76 lines
11 KiB
JavaScript

/**
* 共享资源 depot 的浏览器存储集成场景,使用真实 IndexedDB 事务及原始 object store 计数。
* 覆盖跨项目引用、作用域隔离、回收扫描完整性、摘要去重、事务回滚,以及实际引擎上传后的字节提升。
*/
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
// 注入断言并创建独立数据库;所有领取的 leader 统一记录,最后释放并删库。
export async function assetDepotStorageCase(assert){
const name=`depot-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'p'},leaders=[];
// 生成最小 image 并计算原始测试字节摘要;资源身份元数据与摘要共享字节分开保存。
const image=n=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{n}}),bytes=new Blob(['shared original'],{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='https://source.test/w/assets';
// 构造不同资源 ID 的相同字节引用,以及完整/不完整 manifest 和固定宽限期的回收调用。
const ref=id=>({version:1,authority,digest,asset:{id,url:`https://source.test/w/assets/${id}`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}),cut=(pins=[],writes=[],complete=true)=>({version:1,authority,pins,writes,complete}),collect=(leader,now,extra={})=>storage.assetDepot.collect(leader,{authority,now,retentionMs:10,limit:128,...extra});
// 要求异步调用以指定错误码失败;正常返回或错误码不符都使测试失败。
const rejects=async(fn,code)=>{try{await fn();}catch(e){assert(e.code===code,`expected ${code}, got ${e.code}`);return;}throw Error(`expected ${code}`);};
// 按项目领取 leader 并记录到清理列表,让关闭项目的 manifest 仍可参与共享回收。
const open=async(projectId)=>{const leader=await storage.claim({...scope,projectId});leaders.push(leader);return leader;};
try{
const p=await open('p'),q=await open('q');
await storage.commit(p,{image:image(0),depot:cut([['one',['element:p']]], [{ref:ref('one'),bytes},{ref:ref('two'),bytes}])});
await storage.commit(q,{image:image(0),depot:cut([['two',['history:q']]])});
// 额外打开原始 IDB 连接用于检查物理行数,避免只凭公开读取接口推断去重。
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
// 直接统计 asset-bytes 的物理摘要记录数,区分资源身份数与实际存储的字节副本数。
const count=()=>new Promise((resolve,reject)=>{const tx=database.transaction('asset-bytes'),request=tx.objectStore('asset-bytes').count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
assert(await count()===1,'same digest was physically duplicated across identities');
await p.release();assert((await collect(q,0)).pinnedIds.length===2,'closed project pins were lost');
assert(await(await storage.assetDepot.read(q,authority,'one')).bytes.text()==='shared original','destination could not read shared bytes');
// 分别换账户、工作区及 authority,确认共享缓存只在相同授权作用域内可读。
const otherAccount=await storage.claim({...scope,accountId:'other'});leaders.push(otherAccount);assert(await storage.assetDepot.read(otherAccount,authority,'one')===undefined,'account cache leak');
const otherWorkspace=await storage.claim({...scope,workspaceId:'other'});leaders.push(otherWorkspace);assert(await storage.assetDepot.read(otherWorkspace,authority,'one')===undefined,'workspace cache leak');
assert(await storage.assetDepot.read(q,'https://other.test','one')===undefined,'authority cache leak');
// 缺少索引、不完整或落后于最新 Root 的 manifest 都会阻止安全删除;随后补齐才能继续回收。
const stale=await open('stale');await storage.commit(stale,{image:image(0)});assert((await collect(q,100)).blockedProjects.includes('stale'),'unindexed project allowed deletion');
await storage.commit(stale,{image:image(0),depot:cut([],[],false)});assert(!(await collect(q,100)).complete,'opaque project allowed deletion');
await storage.commit(stale,{image:image(0),depot:cut()});await storage.commit(stale,{image:image(1)});assert(!(await collect(q,100)).complete,'stale manifest allowed deletion');
await storage.commit(stale,{image:image(1),depot:cut()});
// 重新打开源项目清除引用,再经标记和宽限期回收其中一个身份;其他同摘要资源仍持有物理字节。
const reopened=await open('p');await storage.commit(reopened,{image:image(1),depot:cut()});
assert((await collect(q,100)).markedIds.includes('one'),'unreferenced resource was not marked');
assert((await collect(q,110)).purgedIds.includes('one')&&await count()===1,'purging one identity deleted another identity bytes');
await storage.commit(q,{image:image(1),depot:cut()});await collect(q,110);await collect(q,120);assert(await count()===0,'expired unpinned shared bytes retained');
// 清除最后引用后字节应消失,但不可变身份与 purgedAt 仍保留,便于校验后续补回的内容。
const purged=await storage.assetDepot.read(q,authority,'one');assert(purged.purgedAt===110&&!purged.bytes,'immutable identity lost or purged bytes exposed');
// 摘要/元数据冲突必须整批拒绝,不能推进 rootRevision 或写入部分共享字节。
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([], [{ref:{...ref('one'),digest:'0'.repeat(64)},bytes}])}),'shared_asset_bytes_changed');assert((await storage.recover(q)).rootRevision===1,'invalid bytes advanced Root');
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([['one',['element:q']]], [{ref:{...ref('one'),asset:{...ref('one').asset,width:3}},bytes}])}),'shared_asset_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===1,'identity conflict partially committed bytes or Root');
// Force a later files-store conflict after the depot puts: the whole transaction must roll back.
await storage.commit(q,{image:image(2),files:[{id:'existing',bytes}],depot:cut()});
await rejects(()=>storage.commit(q,{image:image(3),files:[{id:'existing',bytes}],depot:cut([['one',['element:q']]], [{ref:ref('one'),bytes}])}),'resource_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===2,'late failure committed depot or Root');
await storage.commit(q,{image:image(3),depot:cut([['one',['element:q']]], [{ref:ref('one'),bytes}])});assert(await count()===1&&(await storage.assetDepot.read(q,authority,'one')).purgedAt===undefined,'authorized rehydration failed');
// A one-item sweep must provide a cursor and preserve each resource decision.
const first=await collect(q,130,{limit:1});assert(first.nextAfter==='one','bounded sweep cursor missing');assert((await collect(q,130,{after:first.nextAfter,limit:1})).nextAfter===undefined,'bounded sweep did not finish');
// Exercise the actual engine persistence entry point, rather than only hand-built batches.
// 从手工 batch 检查切换到真实引擎:stageFiles、确认上传及关闭后,再检查物理文件行。
const projectId='engine-project',registry=createRegistry(builtinComponents());let document=createDocument(projectId,[{id:'canvas',parent:null}],[]),version=1,counter=0,resource,uploads=0;
// 模拟上传回执及 authority;此场景不应触发已有资源接纳,上传返回原始字节的服务端身份。
const receipts=new Map(),assets={authority:{scope:authority,async resolve(){throw Error('unexpected adoption');},async read(){throw Error('unexpected hydration');}},sourceURL:id=>`https://source.test/w/assets/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,data){uploads++;const raw=JSON.parse(request.body);return{outcome:'applied',canonical:{id:raw.resource_id,url:assets.sourceURL(raw.resource_id),width:2,height:1,bytes:data.size,mime_type:data.type}};},async query(){return{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};
// 文档提交保存新 canonical 基线;资源上传和文档确认在此使用内存服务,存储端仍是真 IDB。
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
const engine=await openProjectEngine({scope:{...scope,projectId},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,createId:()=>`engine-${++counter}`});
try{
// 创建图片与上传任务,再有限轮询 stored 状态,避免在异步提升字节前检查存储。
const staged=engine.stageFiles([{name:'source.png',bytes}],([handle],tx)=>{resource=handle.id;tx.dispatch({type:'element.create',element:{id:'picture',incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:'picture',text:'',assetId:handle.id,src:handle.src}}});});assert(staged.status!=='rejected','engine image staging failed');
let ready=false;for(let i=0;i<200;i++){if(engine.getSnapshot().runtime.resources.get(resource)?.stored){ready=true;break;}await new Promise(resolve=>setTimeout(resolve,5));}assert(ready,'engine upload did not settle');
}finally{await engine.close();}
// 读取引擎项目的 files 行,要求上传确认后为 fileVersion=1 指针,而非保留第二份 Blob。
const physical=await new Promise((resolve,reject)=>{const request=database.transaction('files').objectStore('files').get([JSON.stringify([scope.accountId,scope.workspaceId,projectId]),resource]);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(physical?.fileVersion===1&&!(physical instanceof Blob),'actual engine upload retained duplicate project bytes');
// 共享 depot 应能读到提升后的原字节;上传只发生一次,关闭引擎的持久化 pin 仍保护资源。
const promoted=await storage.assetDepot.read(q,authority,resource);assert(promoted?.bytes&&await promoted.bytes.text()==='shared original'&&uploads===1,'engine did not promote acknowledged upload bytes');
assert((await collect(q,200)).pinnedIds.includes(resource),'closed engine did not retain durable document/history pins');
await q.release();await rejects(()=>collect(q,200),'storage_leader_lost');database.close();
}finally{for(const leader of leaders)await leader.release();storage.close();indexedDB.deleteDatabase(name);}
}