Files
Endoleap/tests/headless/orphan-files.test.mjs
T

82 lines
14 KiB
JavaScript

/**
* 本地文件盘点、保留期和回收的引擎测试。是否孤立由所有已知引用及保守覆盖判断,不能仅看资源登记表。
* 内存端口声明 atomic-v1 文件删除能力,随镜像保存模拟文件增加/删除;不验证真实文件系统的原子性。
* 清理不改变业务文档和历史,退休身份则必须持久保留,防止稍后误用同 ID 的已删除字节。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {orphanCoverage} from '../../src/lib/canvas-runtime/orphan-files.ts';
// 图片目标默认没有 assetId,各场景自行添加引用;文件 Map 可包含尚未登记但仍被元素引用的字节。
const registry=createRegistry(builtinComponents()),scope={accountId:'a',workspaceId:'w',projectId:'p'},node=(id,data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:'C',componentId:'image',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...data}}),original=createDocument('p',[{id:'C',parent:null}],[node('A')]);
// 用可控 Promise 挂起读字节,模拟显示借用、取消和 Root 变化与回收扫描交错。
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
// 将文件 Map、业务模型、内存镜像和服务替身组合,保留批次与请求记录用于检查回收是否越界。
function fixture(files=new Map(),document=original){
let image,owner,next=0,server=document,version=1,now=1000;const batches=[],requests=[],receipts=new Map();
// commit 在同一替身调用中处理文件和镜像;scanFiles 必须匹配调用者捕获的 rootRevision,并按文件 ID 分页。
const storage={fileDeletion:'atomic-v1',
// 只允许当前引擎持有文件仓库;释放时同时清除 owner 和 active 标记。
async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},
// 返回克隆恢复镜像,文件字节由独立 files 仓库保留。
async recover(){return image?structuredClone(image):null;},
// 校验 owner,按批次写入/删除字节后保存镜像;记录整个 batch 供清理断言观察。
async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},
// 读取本地 Blob;用例可包装这个方法在真实返回前挂起。
async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},
// 只在指定持久化修订上盘点;排序后取最多 limit 项,用 nextAfter 表示尚有后页。
async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
// 普通文档编辑通过文档端口发送并处理规范回执;清理本身不应该产生任何网络文档请求。
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),
// 应用普通文档修改并保存该请求的规范模型;资源清理场景通过 requests 检查未误入此路径。
async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},
// 只返回此替身保存过的请求结果;没有记录则保持 unresolved。
async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},
// 根据请求取 ACK 基线,供无关编辑的正常同步使用。
canonical:request=>receipts.get(request.id)};
// 复用同一文件仓库与存储镜像以支持重开;advance 只推进保留期墙上时间,网络重试仍使用真实定时器。
return{files,batches,requests,storage,advance:ms=>{now+=ms;},image:()=>image,open:()=>openProjectEngine({scope,registry,document,versions:new Map([['C',1]]),storage,persistence:port,createId:()=>`id-${++next}`,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}})};
}
// 只读盘点按 limit 分页且保持 Root 引用;未登记但被元素引用的文件受保护,只有无引用文件经标记和保留期后删除,文档/history 不变。
test('inventory is bounded and read-only; unregistered but referenced files stay protected',async()=>{
const doc=createDocument('p',[{id:'C',parent:null}],[node('A',{assetId:'used'})]),f=fixture(new Map([['old',new Blob(['old'])],['used',new Blob(['used'])]]),doc),engine=await f.open();try{await engine.persist();const root=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert.equal(engine.getSnapshot(),root);assert.equal(first.files[0].id,'old');assert.equal(first.files[0].ownership,'unregistered');assert.equal(first.nextAfter,'old');const second=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert.equal(second.files[0].id,'used');assert.ok(second.files[0].pins.some(owner=>owner.startsWith('element:')));assert.equal(second.nextAfter,undefined);assert.equal(f.requests.length,0);
const marked=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(marked.markedIds,['old']);assert.deepEqual(marked.purgedIds,[]);assert.equal(f.image().formatVersion,5);f.advance(100);const result=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(result.purgedIds,['old']);assert.equal(f.files.has('old'),false);assert.equal(f.files.has('used'),true);assert.equal(engine.getSnapshot().document,root.document);assert.equal(engine.getSnapshot().history,root.history);assert.equal(f.requests.length,0);decodeRecovery(f.image(),registry);
}finally{await engine.close();}
});
// 退休文件及 v5 镜像重开后仍禁止显示或新增引用,也不能通过清空 orphanFiles 抹去退休证据;无关编辑继续可用,静默降级镜像被拒绝。
test('retired orphan identity and v5 recovery survive reopening and reject new references or silent downgrade',async()=>{
const f=fixture(new Map([['old',new Blob(['old'])]]));let engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:0});const file=engine.getSnapshot().runtime.orphanFiles.get('old');assert.ok(file.retirement.purgedAt!==undefined);for(const formatVersion of [1,2,3,4])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);await engine.close();engine=await f.open();await assert.rejects(engine.displayResource('old'),/local_file_retired/);assert.equal(engine.editor.dispatch({type:'element.create',element:node('bad',{assetId:'old'})}).code,'local_file_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,orphanFiles:new Map()})).code,'local_file_retired');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.files.has('old'),false);
}finally{await engine.close();}
});
// 文件大小与 MIME 相同也可能换了字节;digest 改变必须重置无引用起点,重新等待完整保留期才能删除。
test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{
const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();}
});
// 显示读取一开始即借用并清除软退休标记,不必等 URL 返回;释放后可重新计时,显式 resourcePins 再次阻止删除。
test('display borrowing and explicit owners reset soft markers before collection',async()=>{
const f=fixture(new Map([['old',new Blob(['data'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const opening=engine.displayResource('old');await entered.promise;assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();await engine.collectOrphanFiles({retentionMs:100});engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export',['old']]])}));assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);}finally{gate.resolve();await engine.close();}
});
// 哈希字节预算耗尽要保留 nextAfter 继续扫描;读字节期间 Root 改变会使验证过期,此轮不能删除任何文件。
test('hash budgets preserve pagination progress and a changed Root invalidates read-only verification',async()=>{
const f=fixture(new Map([['a',new Blob(['a'])],['b',new Blob(['bb'])],['c',new Blob(['c'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{const first=await engine.collectOrphanFiles({retentionMs:100,maxHashBytes:1});assert.deepEqual(first.markedIds,['a']);assert.equal(first.nextAfter,'a');assert.equal(first.hashBudgetExhausted,true);assert.equal(first.scanFinished,false);const second=await engine.collectOrphanFiles({retentionMs:100,after:first.nextAfter,maxHashBytes:3});assert.deepEqual(second.markedIds,['b','c']);assert.equal(second.scanFinished,true);
f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const collecting=engine.collectOrphanFiles({retentionMs:0});await entered.promise;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'changed while hashing'}}).status,'applied');gate.resolve();await assert.rejects(collecting,/stale_file_inventory/);assert.equal(f.files.size,3);
}finally{gate.resolve();await engine.close();}
});
// abort 后迟到字节不得发布盘点结果;删除批次保存失败时保留原清理切点及资源访问屏障,persist 重试完成同一次删除。
test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined);
}finally{fail=false;gate.resolve();await engine.close();}
});
// 运行任务、恢复审计、未完成导入和复制准入都是不透明生产者;即使已知资源表为空,引用覆盖仍不完整,不能据此回收。
test('opaque producers block orphan collection even when the known local resource map is empty',async()=>{
const f=fixture(),engine=await f.open();try{const root=engine.getSnapshot(),coverage=runtime=>orphanCoverage({...root,runtime},registry,new Map(),new Map());assert.equal(coverage({...root.runtime,runs:new Map([['run',{id:'run'}]])}).complete,false);assert.ok(coverage({...root.runtime,recoveries:new Map([['audit',{}]])}).blockers.includes('recovery_audits'));assert.ok(coverage({...root.runtime,imports:new Map([['job',{id:'job',status:'paused',source:{fileId:'source'}}]])}).blockers.includes('unfinished_imports'));assert.ok(orphanCoverage(root,registry,new Map(),new Map([['copy-admission',[]]])).blockers.includes('copy_admission'));
}finally{await engine.close();}
});
// 复制准入在 ImportJob 出现前已构成保护;即使调用带已取消 signal,也保守清除软退休标记,下一次扫描从当前时间重新计时。
test('a cancelled copy admission resets orphan retention before any ImportJob is published',async()=>{
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),cancel=new AbortController();try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);cancel.abort();await assert.rejects(engine.imports.duplicate([{id:'A',incarnation:1}],{signal:cancel.signal}),/copy_aborted/);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement.unreferencedAt,1100);}finally{await engine.close();}
});