docs(tests): explain engine import publication recovery and source seals

This commit is contained in:
2026-11-12 10:20:30 +08:00
parent d5ab402592
commit 7b0e734e16
+117 -9
View File
@@ -1,3 +1,9 @@
/**
* ProjectEngine 的渐进导入/深复制集成测试,串联本地文档、history、ledger、源文件、校验及恢复。
* 已发布内容可早于服务端 ACK;分块读取证明支持先显示,完整 SHA 与其持久化 seal 决定何时解除相关网络写入门槛。
* 暂停/关闭/取消、端点换代、旧解释器、资源引用及源文件回收各有独立生命周期,不能用重新插入全部内容来恢复。
* fixture 使用内存存储和幂等假服务端;测试验证引擎协调,不等同于真实 IDB 事务、网络或渲染帧率验证。
*/
import {encodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
import {test} from 'node:test';
@@ -5,29 +11,64 @@ 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';
// 内置类型、空单 canvas 基线及固定作用域,关闭/重开读取同一个项目的恢复 image。
const registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'canvas',parent:null}],[]),scope={accountId:'account',workspaceId:'workspace',projectId:'project'};
// native NDJSON 记录工厂故意携带第八代身份和 runId;导入应生成新身份并去掉执行任务字段。
const node=id=>({kind:'element',key:id,record:{id,incarnation:8,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:id,text:'',runId:'must not run'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}});
// 对象序列编码为 NDJSON,也允许传入原始坏行以检查部分失败。
const file=records=>new Blob([records.map(value=>typeof value==='string'?value:JSON.stringify(value)).join('\n')]);
// 手动 gate 与 entered 信号控制服务端 ACK、摘要、源读取或持久化,避免靠固定延时决定事件顺序。
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
// 准备可重用的源文件/image/服务端文档和回执;broken 在写入前抛配额错误,gate 可挂起服务端提交。
function fixture(initial=original){
let image=null,leader,next=0,server=initial;const versions=new Map([...initial.canvases.keys()].map(id=>[id,1]));const files=new Map(),requests=[],receipts=new Map(),errors=[];
const f={files,requests,errors,gate:null,broken:false,
storage:{fileDeletion:'atomic-v1',async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}},
port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map(versions)};}},
image:()=>image,server:()=>server,open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
storage:{fileDeletion:'atomic-v1',
// 单项目仅一个 leader;release 清空 owner 并撤销活动标记,允许关闭后重新领取。
async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},
// 克隆上次 image;用例重开应通过恢复 codec 读取它,而非复用旧引擎状态。
async recover(){return image&&structuredClone(image);},
// 先核对 leader 并检查配额,再保存源文件/删除/克隆 image;此内存实现不模拟真实事务的部分失败回滚。
async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},
// 按 ID 返回持久化 Blob,且要求当前 leader;用例可替换为挂起读取、损坏字节或 unavailable。
async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},
// 内存端口无外部句柄;引擎负责归还 leader。
close(){}},
port:{
// 编译携带意图和 requestId 的候选请求,参与 canvas 使用当前 ACK 版本,未建立的新 canvas 基线为零。
compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},
// 记录请求并可卡在 gate;同 requestId 已有 receipt 时不再次应用,模拟后端幂等,canonical 使用该次保存的基线。
async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},
// 按请求 ID 查询执行事实;无保存回执时保持 unresolved,不推定零写入。
async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},
// 取对应请求保存的 document/versions,防止用当前 working 冒充服务端确认。
canonical(request){return receipts.get(request.id);},
// 恢复会话读取当前服务端文档及版本表副本,避免外部修改同一个 Map。
async read(){return{document:server,versions:new Map(versions)};}},
// 暴露 fixture 保存的 image 引用,专供旧格式改造和损坏注入;不是产品公开状态接口。
image:()=>image,
// 只读取得当前假服务端文档,供断言本地可见与远端 ACK 之间的差别。
server:()=>server,
// 每次以相同源存储/作用域重新装配引擎;递增 ID 分配器和错误列表跨重开保留。
open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
}
// 先检查当前快照,再订阅到谓词成立或 5 秒超时;两种结束路径都移除订阅,避免泄漏监听器。
function until(engine,predicate){if(predicate(engine.getSnapshot()))return Promise.resolve();return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{unsubscribe();reject(Error('import condition timed out'));},5000),unsubscribe=engine.editor.subscribe((_,snapshot)=>{if(predicate(snapshot)){clearTimeout(timer);unsubscribe();resolve();}});});}
// 阻塞后端提交时三个 native 元素已本地可见且历史合并为一组;新 incarnation=1,runId 被剔除,放行 ACK 后再检查服务端。
test('native import adds visible records while its backend writer is still awaiting ACK',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.server().elements.size,0);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(f.files.size,1);for(const element of engine.getSnapshot().document.elements.values()){assert.equal(element.incarnation,1);assert.equal(element.data.runId,undefined);}gate.resolve();await engine.settle();assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}
});
// 首项后暂停、编辑已导入元素再重开,继续使用持久化源与原 ID,后续导入不能覆盖手动标题。
test('pause/reopen/resume reuses the durable source and IDs without overwriting a user edit',async()=>{
const f=fixture();let engine=await f.open();
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:one'];assert.equal(job.status,'paused');engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'user edit'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'user edit');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.errors.length,0);decodeRecovery(f.image(),registry);}finally{await engine.close();}
});
// 导入前缀、手动编辑、导入后缀是三个有序 undo 单元;重开后逐项 undo/redo 仍保留同一编辑与任务完成事实。
test('import history on either side of a manual edit remains three ordered undo units after reopening',async()=>{
const f=fixture();let engine=await f.open();
try{
@@ -42,64 +83,76 @@ test('import history on either side of a manual edit remains three ordered undo
}finally{await engine.close();}
});
// 取消只保留已加入首项;重开后显式 resume 也不把 cancelled 任务当暂停任务继续插入。
test('cancel keeps the already added prefix and survives reopening without further insertion',async()=>{
const f=fixture();let engine=await f.open();
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,1);await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);}finally{await engine.close();}
});
// 首个端点导入后被用户删除,晚到连线不能通过恢复旧源把它复活;剩余独立节点导入,任务 partial。
test('deleting an imported endpoint while paused prevents a late edge from reviving it',async()=>{
const f=fixture();let engine=await f.open();
const edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'one'},target:{elementId:'two'},path:null,data:{}}};
try{const id=engine.imports.startNdjson(file([node('one'),edge,node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:one'];engine.editor.dispatch({type:'element.remove',target:{id:first,incarnation:1}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.equal(engine.getSnapshot().document.elements.has(first),false);assert.equal(engine.getSnapshot().document.elements.size,1);assert.ok(job.failures.some(failure=>failure.key==='element:edge'));assert.equal(f.server().elements.has(first),false);}finally{await engine.close();}
});
// prepareRecovery 暂停活动导入发布,等待期间 rootRevision 不变;接纳新基线后才继续完成导入。
test('canonical recovery holds active imports and releases them after accepting the fresh base',async()=>{
const f=fixture(),engine=await f.open();
try{const id=engine.imports.startNdjson(file(Array.from({length:20},(_,index)=>node(`n-${index}`))),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length>=1);const session=await engine.prepareRecovery(),cut=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,cut);assert.equal((await session.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,20);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 关闭活动导入保存可继续的 running 检查点,重开自动续跑,首项 ID 不变且无重复节点。
test('closing an active import preserves a running checkpoint and reopening resumes without duplicate records',async()=>{
const f=fixture();let engine=await f.open();
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);const first=engine.imports.getJobs().get(id).stableIds['element:one'];await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 端点删除后 undo 恢复成第二代,旧连线仍不得绑定新身份;记录 import_dependency_changed,保留当前元素。
test('a late connection cannot claim an endpoint restored into a new incarnation',async()=>{
const f=fixture(),engine=await f.open(),edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'one'},target:{elementId:'two'},path:null,data:{}}};
try{const id=engine.imports.startNdjson(file([node('one'),edge,node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:one'];engine.editor.dispatch({type:'element.remove',target:{id:first,incarnation:1}});await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(first).incarnation,2);await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.equal(engine.getSnapshot().document.elements.get(first).incarnation,2);assert.equal(engine.getSnapshot().document.elements.has(job.stableIds['element:edge']),false);assert.ok(job.failures.some(failure=>failure.code==='import_dependency_changed'));}finally{await engine.close();}
});
// 重开读取源仍挂起时取消 job,随后返回 source unavailable 不能改写 cancelled 或产生无关错误。
test('a late unavailable-source result cannot change a job cancelled during reopening',async()=>{
const f=fixture();let engine=await f.open();const gate=deferred(),entered=deferred();
try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.persist();await engine.close();f.storage.readFile=async()=>{entered.resolve();return gate.promise;};engine=await f.open();await entered.promise;await engine.imports.cancel(id);gate.resolve(undefined);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.errors.length,0);}finally{gate.resolve(undefined);await engine.close();}
});
// 普通空恢复 image 可为格式 1,含读取证明后升级到 17;不能仅改 formatVersion 就把证明数据降成旧格式。
test('proof-bearing recovery cuts use format 17 and cannot be silently downgraded to a legacy image',async()=>{
const f=fixture(),engine=await f.open();
try{await engine.persist();assert.equal(f.image().formatVersion,1);const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const image=f.image();assert.equal(image.formatVersion,17);assert.equal(decodeRecovery(image,registry).runtime.imports.get(id).status,'completed');assert.throws(()=>decodeRecovery({...image,formatVersion:1},registry),/unsupported_recovery_version/);}finally{await engine.close();}
});
// 源文件首次保存挂起时首项已可见,但文件未落盘且任何后端请求不得越过屏障;放行后才完成同步。
test('the first valid import record displays while the source write is pending, but no backend request crosses that barrier',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;f.storage.commit=async(owner,batch)=>{if(batch.files?.length){entered.resolve();await gate.promise;}return commit(owner,batch);};
try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await entered.promise;await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length>=1);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,0);assert.equal(f.files.size,0);gate.resolve();await engine.settle();assert.equal(f.files.size,1);assert.equal(f.server().elements.size,2);assert.equal(engine.imports.getJobs().get(id).status,'completed');}finally{gate.resolve();await engine.close();}
});
// 源文件写失败使任务暂停并保留本地首项/ID,网络未发;修复存储后 resume 唤醒原可靠写入流程。
test('resuming after a failed source write preserves IDs and wakes the original durable writer',async()=>{
const f=fixture(),engine=await f.open();
try{await engine.persist();f.broken=true;const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const first=engine.imports.getJobs().get(id).stableIds['element:one'];assert.equal(f.requests.length,0);assert.equal(engine.getSnapshot().document.elements.size,1);f.broken=false;await engine.imports.resume(id);await engine.settle();await until(engine,()=>f.server().elements.size===2);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(f.files.size,1);assert.equal(new Set(f.requests.map(request=>request.id)).size,f.requests.length);}finally{f.broken=false;await engine.close();}
});
// 深复制样本按组件添加共享图片资源和付费任务字段,区分可复用的资源与不能延续的执行状态。
const copyNode=(id,owner='canvas',componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'source',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
// 用一个事务建立上级 C、入口 F、child 成员 A/B 及 C→F 入边,等待同步后作为复制基线。
async function seedCopy(engine){
const records=[copyNode('C'),copyNode('F','canvas','frame'),copyNode('A','child','image'),copyNode('B','child')];
const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:'canvas',componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
const result=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:records[0]});tx.dispatch({type:'element.create',element:records[1]});tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}});for(const element of records.slice(2))tx.dispatch({type:'element.create',element});tx.dispatch({type:'element.create',element:edge});},{label:'Seed copy source'});assert.equal(result.status,'applied');await engine.settle();
}
// 深复制暂停/重开后使用同一源及映射,保留副本上用户编辑、上级入边、共享资源及剔除任务字段的结果。
test('deep copy source/mapping recover and preserve upper incoming source, shared assets and user edits',async()=>{
const f=fixture();let engine=await f.open();
try{
@@ -113,6 +166,7 @@ test('deep copy source/mapping recover and preserve upper incoming source, share
}finally{await engine.close();}
});
// 外部上级 C 删除再恢复成新一代,晚到复制入边报 copy_external_changed;独立子节点副本仍保留。
test('late copied edge cannot attach to an external endpoint deleted and restored as a new incarnation',async()=>{
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();
assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'C',incarnation:1}}).status,'applied');assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('C').incarnation,2);await engine.settle();await engine.imports.resume(id);await engine.settle();
@@ -120,29 +174,35 @@ test('late copied edge cannot attach to an external endpoint deleted and restore
}finally{await engine.close();}
});
// 复制任务取消后仅保留已发布前缀,重开/resume 不再新增副本。
test('copy cancellation retains the published prefix and does not resume on reopening',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const initial=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,initial+1);}finally{await engine.close();}
});
// 尚未准入前的预取消或项目关闭,迟到复制准备结果不能创建 running job;重开后任务表仍为空。
test('preparation abort and project close do not admit a late copy job',async()=>{
const f=fixture(),engine=await f.open();await seedCopy(engine);const controller=new AbortController();controller.abort();await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{signal:controller.signal}),/copy_aborted/);assert.equal(engine.imports.getJobs().size,0);
const pending=engine.imports.duplicate([{id:'F',incarnation:1}]);const rejected=assert.rejects(pending,/copy_aborted|engine_closing/);await engine.close();await rejected;const reopened=await f.open();try{assert.equal(reopened.imports.getJobs().size,0);}finally{await reopened.close();}
});
// 复制本地完成不等待 ACK;任务显示关联发起 viewCanvasId,而复制元素仍归源规则决定的 child,不能混淆二者。
test('copy publishes independently of backend ACK and associates task visibility with its initiating view',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred();try{await seedCopy(engine);f.gate=gate;const before=f.server().elements.size,id=await engine.imports.duplicate([{id:'A',incarnation:1}],{viewCanvasId:'canvas'});await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');const job=engine.imports.getJobs().get(id);assert.equal(job.source.targetCanvasId,'canvas');assert.equal(engine.getSnapshot().document.elements.get(job.stableIds['element:A']).ownerCanvasId,'child');assert.equal(f.server().elements.size,before);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,before+1);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
});
// 复制源保存失败时网络无新请求;恢复后保留准备期 source 与稳定身份计划,最终映射数量符合深复制范围。
test('copy source write failure gates network and resume preserves the planned identities',async()=>{
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const sent=f.requests.length;f.broken=true;const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const before=engine.imports.getJobs().get(id);assert.equal(f.requests.length,sent);f.broken=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(before.source.kind,'duplicate-snapshot');assert.equal(before.preparation.allocationIds.length,0);assert.equal(Object.keys(engine.imports.getJobs().get(id).stableIds).length,5);}finally{f.broken=false;await engine.close();}
});
// 只读策略先拒绝 import/copy 准入,即使选择目标不存在也不先读取/存储源或创建任务。
test('read-only policy rejects copy/import admission without storing a source or a running job',async()=>{
const f=fixture(),engine=await f.open({policy:()=>({enabled:false,code:'read_only',targets:[]})});try{await assert.rejects(engine.imports.duplicate([{id:'missing',incarnation:1}]),/read_only/);assert.throws(()=>engine.imports.startNdjson(file([node('one')]),'canvas'),/read_only/);assert.equal(engine.imports.getJobs().size,0);await engine.settle();assert.equal(f.files.size,0);assert.equal(f.requests.length,0);}finally{await engine.close();}
});
// 大复制在已分配 ID 的准备期暂停并重开,复用 planner 游标而不重扫前阶段;源后来改名不影响捕获快照中的副本标题。
test('preparing copy resumes saved planning cursors and allocated identities without repeating earlier phases',async(t)=>{
const f=fixture(),save=f.storage.commit;let durableCuts=0;f.storage.commit=async(...args)=>{durableCuts++;return save(...args);};let engine=await f.open();try{
await seedCopy(engine);assert.equal(engine.editor.transaction(tx=>{for(let i=0;i<1000;i++)tx.dispatch({type:'element.create',element:copyNode(`extra-${i}`,'child')});}).status,'applied');await engine.settle();
@@ -152,29 +212,35 @@ test('preparing copy resumes saved planning cursors and allocated identities wit
}finally{await engine.close();}
});
// 任务已准入但仍 preparing 时关闭,保存任务与快照,重开自动完成五个源键。
test('closing an admitted preparing copy preserves its task and snapshot for automatic restart',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);assert.equal(engine.imports.getJobs().get(id).status,'preparing');await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).committedSourceKeys.length,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 准备期取消持久化后重开仍 cancelled,原文档数量不变,不发布任何迟到副本。
test('cancel during preparation is durable and never publishes a copy on reopening',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const size=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 准备时使用的 registry.copy 声明改变,重开后明确暂停为 copy_interpreter_changed,不用新规则静默解释旧快照。
test('a reopened preparation rejects changed registry copy declarations instead of silently reinterpreting the source',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.close();
const changed=createRegistry(builtinComponents().map(component=>component.id==='image'?{...component,copy:{...component.copy,omit:[...component.copy.omit,['customExecution']]}}:component));engine=await f.open({registry:changed});await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'paused');assert.equal(job.error,'copy_interpreter_changed');assert.equal(job.committedSourceKeys.length,0);
}finally{await engine.close();}
});
// 恢复会话 hold 期间 preparing 任务不前进,接纳协调后的状态根后才重新启动并完成。
test('canonical recovery holds a preparing copy and restarts it only after the coordinated root is adopted',async()=>{
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);const recovery=await engine.prepareRecovery(),cut=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,cut);assert.equal(engine.imports.getJobs().get(id).status,'preparing');assert.equal((await recovery.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// frame 端口引用成员、成员归属 child、child 绑定 frame 形成依赖环,必须一次闭合发布;深复制后端口局部 ID/handle 保留、nodeId 映射,重开与 undo/redo 都可恢复。
test('native frame ports and deep copy close ownership/reference cycles atomically, keep local handles and recover every binding',async()=>{
const f=fixture();let engine=await f.open();
const frame={...node('F'),record:{...node('F').record,componentId:'frame',data:{title:'Frame',text:'',ports:[{id:'input',direction:'input',nodeId:'A',handle:'left'}]}}},child={...node('A'),record:{...node('A').record,ownerCanvasId:'S'}},canvas={kind:'canvas',key:'S',record:{id:'S',parent:{canvasId:'$target',entryElementId:'F'}}};
try{
// 观察每次发布的 frame 端口是否已经有目标成员,用 0/2 元素数量检测依赖环的半成品是否泄漏。
const cuts=[],unsubscribe=engine.editor.subscribe((_,snapshot)=>{const rows=[...snapshot.document.elements.values()],frames=rows.filter(row=>row.componentId==='frame');for(const row of frames){const port=row.data.ports[0];assert.ok(snapshot.document.elements.has(port.nodeId),'published a frame without its port target');assert.notEqual(port.nodeId,'A');}cuts.push(rows.length);});
const imported=engine.imports.startNdjson(file([frame,canvas,child]),'canvas');await engine.settle();const first=engine.imports.getJobs().get(imported);assert.equal(first.status,'completed');assert.equal(first.committedSourceKeys.length,3);const F=first.stableIds['element:F'],A=first.stableIds['element:A'];assert.equal(engine.getSnapshot().document.elements.get(F).data.ports[0].nodeId,A);assert.ok(cuts.every(size=>size===0||size===2));unsubscribe();
const copied=await engine.imports.duplicate([{id:F,incarnation:1}]);await engine.settle();const job=engine.imports.getJobs().get(copied);assert.equal(job.status,'completed');assert.equal(job.committedSourceKeys.length,3);const F2=job.stableIds[`element:${F}`],A2=job.stableIds[`element:${A}`],port=engine.getSnapshot().document.elements.get(F2).data.ports[0];assert.deepEqual(port,{id:'input',direction:'input',nodeId:A2,handle:'left'});assert.notEqual(A2,A);assert.equal(f.server().elements.size,4);
@@ -182,6 +248,7 @@ test('native frame ports and deep copy close ownership/reference cycles atomical
}finally{await engine.close();}
});
// 源文件保存一直挂起时允许有限本地前缀,未持久化根差达到背压门槛后停止增长;所有网络请求仍为零,放行才完成。
test('blocked source persistence admits several local batches then applies bounded backpressure and gates every request',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;
try{
@@ -191,14 +258,17 @@ test('blocked source persistence admits several local batches then applies bound
}finally{gate.resolve();await engine.close();}
});
// 复制快照字节同长度篡改也会被摘要绑定识别;恢复前无新副本/网络写,原摘要不被替换成篡改源的身份。
test('same-length changes to an admitted copy snapshot are rejected before preparation allocates or publishes content',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const size=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),raw=await f.files.get(job.source.fileId).text(),changed=raw.replace('source','tamper');assert.equal(changed.length,raw.length);await engine.close();const sent=f.requests.length;f.files.set(job.source.fileId,new Blob([changed]));engine=await f.open();await engine.imports.resume(id);await engine.settle();const blocked=engine.imports.getJobs().get(id);assert.equal(blocked.status,'paused');assert.equal(blocked.error,'import_source_changed');assert.equal(blocked.committedSourceKeys.length,0);assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(f.requests.length,sent);assert.equal(blocked.source.sha256,job.source.sha256);}finally{await engine.close();}
});
// native 尚未解析的尾部被同长度改写,重开验证拒绝并保留原首项,不允许改源后继续沿用旧任务进度。
test('a changed unparsed tail cannot pass native import recovery or alter its preserved prefix',async()=>{
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),raw=await f.files.get(job.source.fileId).text(),changed=raw.replaceAll('three','other');assert.equal(changed.length,raw.length);const first=job.stableIds['element:one'];await engine.close();const sent=f.requests.length;f.files.set(job.source.fileId,new Blob([changed]));engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_changed');assert.equal(engine.getSnapshot().document.elements.size,1);assert.ok(engine.getSnapshot().document.elements.has(first));assert.equal(f.requests.length,sent);}finally{await engine.close();}
});
// 摘要读取挂起期间取消或开启恢复会话,迟到结果不能复活 cancelled,也不能在 held root 发布;释放协调后才可继续。
test('late source digest cannot revive a cancelled job or publish into a held recovery root',async()=>{
for(const cancel of [true,false]){
const f=fixture(),engine=await f.open(),entered=deferred(),gate=deferred(),bytes=new class extends Blob{slice(...args){return new class extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}([super.slice(...args)]);}}([JSON.stringify(node('one'))]);
@@ -206,62 +276,73 @@ test('late source digest cannot revive a cancelled job or publish into a held re
}
});
// 旧未完成源缺乏完整性绑定仍可读取已有检查点,但 resume 明确拒绝补造新身份;原前缀与网络状态保留。
test('legacy unfinished sources remain readable but cannot acquire a new content identity silently',async()=>{
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.version=2;delete job.source.sha256;delete job.source.integrityVersion;delete job.source.readProof;delete job.source.integrityError;image.formatVersion=2;const sent=f.requests.length;engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_integrity_unavailable');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,sent);decodeRecovery(f.image(),registry);}finally{await engine.close();}
});
// 把保存的索引源改造为旧完整快照,重算长度/SHA/fingerprint 并去掉 snapshotVersion,使旧准备解释器真正读取旧格式。
async function legacyCopySource(f,job,document){const signal=new AbortController().signal,bytes=await encodeCopySnapshot(document,signal);delete job.source.snapshotVersion;job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes,signal);job.sourceFingerprint=`local-file:${job.source.fileId}:${bytes.size}`;f.files.set(job.source.fileId,bytes);}
// 旧 preparation v1 已预留 legacy-frame-id,转换成旧源格式后恢复应沿用该 ID 而不再分配。
test('legacy preparation version 1 still replays its reserved identities instead of allocating replacements',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const sourceDocument=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=1;job.preparation.steps=1;job.preparation.allocationIds=['legacy-frame-id'];delete job.preparation.planner;await legacyCopySource(f,job,sourceDocument);engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:F'],'legacy-frame-id');assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 复制先转换并发布合法前缀,遇未知尾节点保留 partial;只存原快照而非第二份完整 mapped Blob,重开仍可整体撤销副本。
test('copy converts and publishes a prefix before the output tail, preserves it on failure and stores only the original source',async()=>{
const source=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}],[copyNode('F','canvas','frame'),...Array.from({length:100},(_,i)=>copyNode(`stream-${i}`,'child')),{...copyNode('opaque','child'),componentId:'unknown-widget'}]),f=fixture(source);let engine=await f.open();
try{let prefix;const release=engine.editor.subscribe((_,snapshot)=>{for(const job of snapshot.runtime.imports.values())if(!prefix&&job.committedSourceKeys.length)prefix={cursor:job.cursor,total:job.source.plan.order.length+job.source.plan.edges.length,status:job.status};});const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert.ok(prefix.cursor<prefix.total,'entire mapped output was converted before the first publication');assert.equal(prefix.status,'running');assert.equal(job.source.kind,'duplicate-plan');assert.equal(job.parserVersion,2);assert.equal(job.source.plan.emitted,0);assert.equal(job.status,'partial');assert.equal(job.committedSourceKeys.length,102);assert.ok(job.failures.some(failure=>failure.key==='element:opaque'&&failure.code==='copy_schema_unavailable'));assert.equal(engine.getSnapshot().document.elements.size,203);assert.equal(f.files.size,1,'copy stored a second complete mapped file');assert.equal(f.server().elements.size,203);assert.equal(f.errors.length,0);await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,203);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,102);
}finally{await engine.close();}
});
// 旧准备版本 2/3 重开仍分别使用 duplicate-ndjson/parser1 与 duplicate-plan/parser2,不能用当前格式偷换解释器。
test('legacy preparation versions 2 and 3 retain their source interpreters after reopening',async()=>{
for(const version of [2,3]){const f=fixture();let engine=await f.open();try{await seedCopy(engine);const sourceDocument=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=version;await legacyCopySource(f,job,sourceDocument);engine=await f.open();await engine.imports.resume(id);await engine.settle();const done=engine.imports.getJobs().get(id);assert.equal(done.status,'completed');assert.equal(done.source.kind,version===2?'duplicate-ndjson':'duplicate-plan');assert.equal(done.parserVersion,version===2?1:2);assert.equal(f.errors.length,0);}finally{await engine.close();}}
});
// 恢复复制的 16 KiB 源解码挂起时,cancel 保留旧前缀;恢复会话 hold 则阻止后续发布,接纳后再完成。
test('late planned-source decoding respects cancellation and canonical recovery holds',async()=>{
for(const cancel of [true,false]){
const f=fixture();let engine=await f.open();const entered=deferred(),gate=deferred();try{
await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),size=engine.getSnapshot().document.elements.size;await engine.close();
class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
class DelayedSource extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return end-start===16*1024?new DelayedChunk([part]):part;}}
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
/* 仅拦住 16 KiB 源解码窗口,其他读取保留真实 Blob 行为。 */ class DelayedSource extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return end-start===16*1024?new DelayedChunk([part]):part;}}
f.files.set(before.source.fileId,new DelayedSource([f.files.get(before.source.fileId)]));engine=await f.open();const resuming=engine.imports.resume(id);await entered.promise;
if(cancel){await engine.imports.cancel(id);gate.resolve();await resuming;await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,size);}else{const preparing=engine.prepareRecovery();gate.resolve();await resuming;const session=await preparing,revision=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal((await session.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');}assert.equal(f.errors.length,0);
}finally{gate.resolve();await engine.close();}
}
});
// 索引已可用时,晚到大正文块挂起不阻塞前缀副本可见;放行后才读取剩余内容并完成全部副本。
test('indexed copy displays a prefix while a later raw payload read is still blocked',async()=>{
const source=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}],[copyNode('F','canvas','frame'),...Array.from({length:100},(_,i)=>({...copyNode(`lazy-${i}`,'child'),data:{title:`lazy-${i}`,text:'x'.repeat(3000)}}))]),f=fixture(source);let engine=await f.open();const entered=deferred(),gate=deferred();try{
const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id);await engine.close();const bytes=f.files.get(before.source.fileId),rows=(await bytes.text()).trimEnd().split('\n');let payload=0;
for(const text of rows){const row=JSON.parse(text);payload+=new TextEncoder().encode(text+'\n').byteLength;if(row.kind==='payload')break;}
class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
class DelayedPayload extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return start>=payload+64*1024&&(start-payload)%(64*1024)===0?new DelayedChunk([part]):part;}}
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
/* 仅从正文区第二个对齐 64 KiB 窗口起挂起;头部、索引和首个原始正文窗口仍可读。 */ class DelayedPayload extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return start>=payload+64*1024&&(start-payload)%(64*1024)===0?new DelayedChunk([part]):part;}}
f.files.set(before.source.fileId,new DelayedPayload([bytes]));engine=await f.open();await engine.imports.resume(id);await entered.promise;const cut=engine.getSnapshot(),job=cut.runtime.imports.get(id);assert.equal(job.source.snapshotVersion,2);assert.equal(job.status,'running');assert.ok(job.committedSourceKeys.length>0&&job.committedSourceKeys.length<102);assert.ok(cut.document.elements.size>source.elements.size&&cut.document.elements.size<202,'raw tail read gated the entire copy');gate.resolve();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,202);assert.equal(f.errors.length,0);
}finally{gate.resolve();await engine.close();}
});
// 两个项目共享摘要池,关闭一个只取消它的请求,不关闭池或影响另一个项目;被关闭项目重开后仍可继续。
test('closing a project aborts its injected digest without closing another project sharing the pool',async()=>{
const {createDigestPool}=await import('../../src/lib/canvas-runtime/digest-pool.ts'),entered=deferred();let first=true;const pool=createDigestPool({concurrency:1,execute:async(bytes,signal)=>{if(first){first=false;entered.resolve();await new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}return importSourceDigest(bytes,signal);}}),a=fixture(),b=fixture();let ea=await a.open({digests:pool}),eb=await b.open({digests:pool});
try{const aid=ea.imports.startNdjson(file([node('A')]),'canvas');await entered.promise;const bid=eb.imports.startNdjson(file([node('B')]),'canvas');await ea.close();await eb.settle();assert.equal(eb.imports.getJobs().get(bid).status,'completed');assert.equal(eb.getSnapshot().document.elements.size,1);assert.equal(a.errors.length,0);assert.equal(b.errors.length,0);ea=await a.open();await ea.settle();assert.equal(ea.imports.getJobs().get(aid).status,'completed');assert.equal(ea.getSnapshot().document.elements.size,1);}finally{await ea.close();await eb.close();pool.close();}
});
// 只手动推进回收墙上时间,真实定时器仍服务于调度;用于精确检查源文件退休宽限期。
const retentionClock=()=>{let now=1000;return{now:()=>now,advance:ms=>{now+=ms;},setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
// 任务本地终态后源文件可标记/宽限期回收,即使文档请求还未 ACK;内容、history 和冻结请求体保持,重开 undo/redo 不依赖已退休源。
test('terminal import source collection preserves every imported record, undo unit and pending request across source-v2 reopening',async()=>{
const f=fixture(),clock=retentionClock(),gate=deferred();f.gate=gate;let engine=await f.open({clock});try{
const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');await engine.persist();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId,document=engine.getSnapshot().document,history=engine.getSnapshot().history;
const marked=await engine.collectImportSources({retentionMs:100});assert.deepEqual(marked.markedIds,[fileId]);assert.deepEqual(marked.purgedIds,[]);assert.ok(f.files.has(fileId));assert.equal(f.image().formatVersion,17);clock.advance(100);
const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.purgedIds,[fileId]);assert.equal(f.files.has(fileId),false);assert.equal(engine.getSnapshot().document,document);assert.equal(engine.getSnapshot().history,history);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'));
// 保存所有冻结请求的 ID/body;源文件回收不应重写它们,后续重开逐个比较。
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()].map(request=>[request.id,request.body]);decodeRecovery(f.image(),registry);for(const formatVersion of [1,2,3])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);
gate.resolve();await engine.settle();await engine.close();engine=await f.open({clock});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);for(const [id,body]of frozen)assert.equal(engine.getSnapshot().runtime.ledger.requests.get(id).body,body);await assert.rejects(engine.imports.openSource(id),/import_source_retired/);
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.size,2);assert.equal(f.errors.length,0);
@@ -269,6 +350,7 @@ test('terminal import source collection preserves every imported record, undo un
}finally{gate.resolve();await engine.close();}
});
// paused 源仍需保留并可 openSource;partial/cancelled 源可回收,重开后不能借 resume 复活内容或重复失败。
test('paused source remains recoverable while partial and cancelled sources become collectible without reviving content',async()=>{
const f=fixture();let engine=await f.open();try{
const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const source=engine.imports.getJobs().get(id).source;assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);assert.ok(f.files.has(source.fileId));const handle=await engine.imports.openSource(id);assert.equal(await handle.bytes.text(),await file([node('one'),node('two'),node('three')]).text());handle.release();
@@ -276,6 +358,7 @@ test('paused source remains recoverable while partial and cancelled sources beco
}finally{await engine.close();}
});
// 打开终态源前先 pin 并清除 soft marker,异步读取期间不可删;最后 release 后重新开始完整宽限期。
test('opening a terminal source pins before I/O, clears its soft marker and restarts the full retention interval',async()=>{
const f=fixture(),clock=retentionClock(),engine=await f.open({clock}),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;await engine.collectImportSources({retentionMs:100});clock.advance(200);f.storage.readFile=async(...args)=>{if(args[1]===fileId){entered.resolve();await gate.promise;}return read(...args);};
@@ -283,6 +366,7 @@ test('opening a terminal source pins before I/O, clears its soft marker and rest
}finally{gate.resolve();await engine.close();}
});
// 源文件删除落盘失败保留字节与 reservation,阻止 openSource 及非法恢复 running;persist 补成同一 purge cut 后才退休,内容仍可编辑。
test('failed source deletion retains byte identity and blocks late reads until the same purge cut becomes durable',async()=>{
const f=fixture(),engine=await f.open(),commit=f.storage.commit;let fail=false;try{
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('source disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectImportSources({retentionMs:0}),/source disk failure/);assert.ok(f.files.has(fileId));await assert.rejects(engine.imports.openSource(id),/resource_collection_in_progress/);const root=engine.getSnapshot(),job=root.runtime.imports.get(id);assert.equal(engine.editor.updateRuntime(previous=>{const jobs=new Map(previous.imports);jobs.set(id,{...job,status:'running',source:{...job.source,retirement:undefined}});return{...previous,imports:jobs};}).status,'rejected');assert.equal(engine.getSnapshot(),root);
@@ -290,6 +374,7 @@ test('failed source deletion retains byte identity and blocks late reads until t
}finally{fail=false;await engine.close();}
});
// 旧快照转 NDJSON 时归档 previousFiles,旧代可单独 pin/回收;当前代完成后也可回收,元素引用和历史仍可恢复。
test('legacy snapshot conversion records its previous file, then collects both generations without losing copied references',async()=>{
const f=fixture();let engine=await f.open();try{
await seedCopy(engine);const captured=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1],bytes=await encodeCopySnapshot(captured,new AbortController().signal),oldFile=job.source.fileId;f.files.set(oldFile,bytes);job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes);job.sourceFingerprint=`local-file:${oldFile}:${bytes.size}`;delete job.source.snapshotVersion;job.preparation={version:1,registrySignature:job.preparation.registrySignature,selection:[{id:'F',incarnation:1}],worldOffset:{x:32,y:32},allocationIds:[],steps:0};decodeRecovery(image,registry);
@@ -298,6 +383,7 @@ test('legacy snapshot conversion records its previous file, then collects both g
}finally{await engine.close();}
});
// openSource 验证当前文件与绑定摘要;篡改拒绝并释放 pin,读取期间关闭项目则迟到结果报 engine_closing。
test('source read handles verify bound bytes and never publish a result after project close',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId,bytes=f.files.get(fileId),text=await bytes.text();f.files.set(fileId,new Blob([text.replace('one','two')]));await assert.rejects(engine.imports.openSource(id),/import_source_changed/);assert.equal(engine.getResourcePins().pins.has(fileId),false);f.files.set(fileId,bytes);
@@ -305,32 +391,41 @@ test('source read handles verify bound bytes and never publish a result after pr
}finally{gate.resolve();await engine.close();}
});
// JSON 数组任务保存原字节及字节游标,暂停/编辑/重开再续跑保持原 ID 和用户标题,证明型 image 不允许格式降级。
test('JSON array import persists original bytes and byte cursor, resumes after reopen, and preserves edits without duplicating IDs',async()=>{const f=fixture();let engine=await f.open();try{const bytes=new Blob([JSON.stringify([node('json-one'),node('json-two'),node('json-three')])]),id=engine.imports.startJsonArray(bytes,'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:json-one'];assert.equal(job.parserVersion,3);assert.equal(job.source.kind,'native-json-array');assert.ok(job.cursor>0);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:13},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:16},registry),/unsupported_recovery_version/);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'preserved edit'}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'preserved edit');assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}});
// 数组尾逗号使任务 partial,只保留解析器已接受的合法前缀;即使后端 gate 未放行,本地也能展示该前缀和显式错误。
test('JSON array malformed tail keeps its visible prefix and an explicit failure while backend waits',async()=>{const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;try{const id=engine.imports.startJsonArray(new Blob(['['+JSON.stringify(node('json-first'))+','+JSON.stringify(node('json-bad'))+',]']),'canvas');await until(engine,s=>s.runtime.imports.get(id).status==='partial');const job=engine.imports.getJobs().get(id);assert.equal(job.committedSourceKeys.length,1);assert.equal(job.failures.at(-1).code,'invalid_json_array');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,1);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}});
// 导出文档导入指定目标根,生成新元素/子 canvas ID,重建 owner 与上级连线;旧源墓碑不移植为目标身份,重开保留四元素。
test('exported document imports into a chosen root with fresh IDs, child ownership and upper connections then reopens',async()=>{const f=fixture();let engine=await f.open();try{const frame={...node('frame').record,componentId:'frame',ownerCanvasId:'source-root'},child={...node('child').record,ownerCanvasId:'source-child'},outside={...node('outside').record,ownerCanvasId:'source-root'},edge={id:'incoming',incarnation:1,ownerCanvasId:'source-root',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'outside'},target:{elementId:'frame'},data:{},path:null},input={documentFormatVersion:1,projectId:'source-root',canvases:[{id:'source-root',parent:null},{id:'source-child',parent:{canvasId:'source-root',entryElementId:'frame'}}],elements:[frame,child,outside,edge],tombstones:{old:2}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'completed',JSON.stringify(job.failures));const doc=engine.getSnapshot().document,frameId=job.stableIds['element:frame'],childId=job.stableIds['canvas:source-child'];assert.equal(doc.canvases.size,2);assert.equal(doc.canvases.get(childId).parent.entryElementId,frameId);assert.equal(doc.elements.get(job.stableIds['element:child']).ownerCanvasId,childId);assert.equal(doc.elements.get(job.stableIds['element:incoming']).ownerCanvasId,'canvas');assert.equal(doc.elements.get(job.stableIds['element:incoming']).target.elementId,frameId);assert.equal(doc.tombstones.has('old'),false);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,4);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(f.errors.length,0);}finally{await engine.close();}});
// 无资源授权端口时图片引用报 import_resource_authorization_required,独立文本仍进入文档并同步。
test('document resource references report an authorization failure while independent text imports',async()=>{const f=fixture(),engine=await f.open();try{const image={...node('image').record,componentId:'image',ownerCanvasId:'source',data:{title:'image',assetId:'untrusted',src:'https://assets.test/untrusted'}},text={...node('text').record,ownerCanvasId:'source'},input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[image,text],tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(row=>row.code==='import_resource_authorization_required'));assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);}finally{await engine.close();}});
// 导出文档使用记录序号恢复,首项后暂停编辑,重开继续不覆盖手动标题或重复元素。
test('paused exported document resumes ordinal progress after reopen without overwriting an edited prefix',async()=>{const f=fixture();let engine=await f.open();try{const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:['d-one','d-two','d-three'].map(id=>({...node(id).record,ownerCanvasId:'source'})),tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:d-one'];assert.equal(job.cursor,1);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'local edit'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'local edit');assert.equal(f.errors.length,0);}finally{await engine.close();}});
// parser 5 尾部扫描挂起时首项已可编辑;暂停/重开继续同解释器和稳定 ID,保留编辑,错误 image 版本拒绝。
test('streaming exported document publishes an editable prefix before the parser tail and resumes parser 5 after reopen',async()=>{
const f=fixture(),gate=deferred(),entered=deferred();let block=true,engine=await f.open();
class Source extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
/* 文档扫描从 32 KiB 起的 16 KiB 窗口可挂起;前项仍可先解析和发布。 */ class Source extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('stream-first').record,ownerCanvasId:'source'},{...node('stream-tail').record,ownerCanvasId:'source',data:{title:'tail',text:'x'.repeat(100000)}}],tombstones:{}},id=engine.imports.startDocument(new Source([JSON.stringify(input)]),'canvas');
await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.includes('element:stream-first'));
const job=engine.imports.getJobs().get(id),first=job.stableIds['element:stream-first'];assert.equal(job.parserVersion,5);assert.equal(engine.getSnapshot().document.elements.size,1);
assert.equal(engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'edited while tail blocked'}}).status,'applied');
// 先请求暂停再释放底层扫描,验证迟到尾部不会绕过暂停;随后持久化 parser/proof 以供重开。
const paused=engine.imports.pause(id);block=false;gate.resolve();await paused;await engine.settle();assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:15},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:18},registry),/unsupported_recovery_version/);
await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'edited while tail blocked');assert.equal(f.server().elements.size,2);assert.equal(f.errors.length,0);
}finally{block=false;gate.resolve();await engine.close();}
});
// 文档 footer 墓碑非法时已导入元素仍可见并同步;partial 及错误诊断持久化,重开保留该成功前缀。
test('bad document footer produces durable partial success rather than discarding imported content',async()=>{
const f=fixture();let engine=await f.open();try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('legal').record,ownerCanvasId:'source'}],tombstones:{broken:0}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(row=>row.code==='invalid_document_import'));assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// 显式构造旧格式 15/parser4/integrity1 检查点,恢复沿用原解释器及 ID,不能暗升成 parser5 或改变 image 格式。
test('legacy format 15 parser 4 document jobs keep their original interpreter and stable IDs when resumed',async()=>{
const f=fixture();let engine=await f.open();try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:['legacy-one','legacy-two'].map(id=>({...node(id).record,ownerCanvasId:'source'})),tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:legacy-one'];await engine.close();
@@ -339,9 +434,10 @@ test('legacy format 15 parser 4 document jobs keep their original interpreter an
}finally{await engine.close();}
});
// 临时正文 I/O 错误暂停在原 ordinal,eof=false 且无坏记录诊断;恢复端口后继续尾项,保留首项手动编辑。
test('temporary document byte I/O pauses without consuming a bad-tail ordinal and resumes with edited prefix intact',async()=>{
const f=fixture(),engine=await f.open();let fail=true;
class Flaky extends Blob{slice(start=0,end=this.size,...rest){if(fail&&end-start===16384&&start>=16384)throw Error('temporary source I/O');return super.slice(start,end,...rest);}}
/* 仅让后续 16 KiB 扫描窗口抛临时 I/O;切换 fail 后同一 Blob 可读,不改变字节身份。 */ class Flaky extends Blob{slice(start=0,end=this.size,...rest){if(fail&&end-start===16384&&start>=16384)throw Error('temporary source I/O');return super.slice(start,end,...rest);}}
try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('io-first').record,ownerCanvasId:'source'},{...node('io-tail').record,ownerCanvasId:'source',data:{title:'tail',text:'x'.repeat(100000)}}]},id=engine.imports.startDocument(new Flaky([JSON.stringify(input)]),'canvas');
await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const job=engine.imports.getJobs().get(id),first=job.stableIds['element:io-first'];assert.equal(job.error,'import_source_read_failed');assert.equal(job.eof,false);assert.equal(job.cursor,1);assert.equal(job.failures.length,0);assert.equal(job.committedSourceKeys.length,1);
@@ -349,52 +445,63 @@ test('temporary document byte I/O pauses without consuming a bad-tail ordinal an
}finally{fail=false;await engine.close();}
});
// 完整 SHA 尚未返回时读取证明允许先发布/编辑前缀;即使 persist 本地内容,网络仍不发,待摘要成功后才同步用户编辑。
test('native import publishes and edits a proof-backed prefix before full SHA, with every request waiting for its durable seal',async()=>{
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({digests:{async digest(bytes,signal){entered.resolve();await gate.promise;return importSourceDigest(bytes,signal);}}});
try{const id=engine.imports.startNdjson(file([node('early-one'),node('early-two')]),'canvas');await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);const job=engine.imports.getJobs().get(id),first=job.stableIds['element:early-one'];assert.equal(job.source.integrityVersion,2);assert.ok(job.source.readProof.blocks.length);assert.equal(job.source.sha256,undefined);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'edit before hash'}}).status,'applied');await engine.persist();assert.equal(f.requests.length,0);assert.equal(f.server().elements.size,0);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);gate.resolve();await engine.settle();assert.ok(engine.imports.getJobs().get(id).source.sha256);assert.equal(f.server().elements.get(first).data.title,'edit before hash');assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
});
// 内存中已有完整 SHA 仍不足以放行网络;必须等含该 seal 的恢复 cut 真正落盘,再允许相关文档请求。
test('a verified SHA still cannot release pending import writes before the seal cut is durable',async()=>{
const f=fixture(),hash=deferred(),save=deferred(),saving=deferred(),commit=f.storage.commit,engine=await f.open({digests:{async digest(bytes,signal){await hash.promise;return importSourceDigest(bytes,signal);}}});
try{const id=engine.imports.startNdjson(file([node('seal-one')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length===1);await engine.persist();f.storage.commit=async(owner,batch)=>{if(batch.image.payload.runtime.imports?.some(([key,job])=>key===id&&job.source.sha256)){saving.resolve();await save.promise;}return commit(owner,batch);};hash.resolve();await saving.promise;assert.ok(engine.imports.getJobs().get(id).source.sha256);assert.equal(f.requests.length,0);save.resolve();await engine.settle();assert.equal(f.server().elements.size,1);assert.equal(f.errors.length,0);}finally{hash.resolve();save.resolve();await engine.close();}
});
// 未封存前 pause/cancel 保留可见前缀、继续挡网络且暂不可回收源;校验后只同步已接受内容,不改变暂停/取消终态。
test('pause and cancel retain the unverified prefix and never turn off the source gate early',async()=>{
for(const cancel of [false,true]){const f=fixture(),gate=deferred(),engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;return importSourceDigest(bytes,signal);}}});try{const id=engine.imports.startNdjson(file([node('stop-one'),node('stop-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);if(cancel)await engine.imports.cancel(id);else await engine.imports.pause(id);await engine.persist();assert.equal(f.requests.length,0);assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);const count=engine.getSnapshot().document.elements.size;gate.resolve();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,cancel?'cancelled':'paused');assert.equal(f.server().elements.size,count);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}}
});
// 源验证失败保留本地内容和稳定 ID,阻止发送;修复 digest 端口再 resume,只补验证与剩余导入,不重复身份。
test('a source verification failure retains local content, blocks sending and can retry without duplicate identities',async()=>{
const f=fixture(),gate=deferred();let fail=true;const engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;if(fail)throw Error('digest unavailable');return importSourceDigest(bytes,signal);}}});
try{const id=engine.imports.startNdjson(file([node('retry-one'),node('retry-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);gate.resolve();await engine.settle();const job=engine.imports.getJobs().get(id),ids={...job.stableIds};assert.equal(job.source.integrityError,'import_source_verification_failed');assert.equal(f.requests.length,0);assert.ok(engine.getSnapshot().document.elements.size>0);fail=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');for(const[key,id]of Object.entries(ids))assert.equal(engine.imports.getJobs().get(job.id).stableIds[key],id);assert.equal(f.server().elements.size,2);assert.equal(engine.imports.getJobs().get(id).source.integrityError,undefined);}finally{fail=false;gate.resolve();await engine.close();}
});
// 未 seal 的持久化检查点重开保留原前缀/ID,仍挡发送;重新验证并续跑后才同步完整内容。
test('an unsealed durable checkpoint reopens with the same prefix and keeps sends blocked until reverified',async()=>{
const f=fixture(),gate=deferred(),entered=deferred();let engine=await f.open({digests:{digest(bytes,signal){entered.resolve();return new Promise((resolve,reject)=>{const abort=()=>reject(Error('aborted'));signal.addEventListener('abort',abort,{once:true});gate.promise.then(()=>{signal.removeEventListener('abort',abort);importSourceDigest(bytes,signal).then(resolve,reject);});});}}});
try{const id=engine.imports.startNdjson(file([node('reopen-one'),node('reopen-two')]),'canvas');await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.pause(id);await engine.persist();const ids={...engine.imports.getJobs().get(id).stableIds};assert.equal(f.image().formatVersion,17);await engine.close();const nextGate=deferred();engine=await f.open({digests:{async digest(bytes,signal){await nextGate.promise;return importSourceDigest(bytes,signal);}}});await engine.persist();assert.equal(f.requests.length,0);assert.deepEqual(engine.imports.getJobs().get(id).stableIds,ids);nextGate.resolve();await engine.imports.resume(id);await engine.settle();assert.equal(f.server().elements.size,2);for(const[key,id]of Object.entries(ids))assert.equal(engine.imports.getJobs().get(engine.imports.getJobs().keys().next().value).stableIds[key],id);}finally{gate.resolve();await engine.close();}
});
// 恢复会话取消旧 verifier 后,接纳基线需重新启动验证;不能留下无错误但永远不放行网络的静默 hold。
test('canonical recovery restarts an aborted unsealed-source verifier instead of leaving a silent send hold',async()=>{
const f=fixture(),gate=deferred();let calls=0;const engine=await f.open({digests:{digest(bytes,signal){calls++;return new Promise((resolve,reject)=>{const abort=()=>reject(Error('aborted'));signal.addEventListener('abort',abort,{once:true});gate.promise.then(()=>{signal.removeEventListener('abort',abort);importSourceDigest(bytes,signal).then(resolve,reject);});});}}});
try{const id=engine.imports.startNdjson(file([node('hold-one'),node('hold-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);const session=await engine.prepareRecovery();assert.equal((await session.apply()).status,'applied');for(let i=0;i<100&&calls<2;i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(calls>=2,'source verifier did not restart after recovery released');assert.equal(f.requests.length,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,2);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
});
// 未 seal 源重开先重验已记录块,篡改即阻塞且不再分配 ID;恢复原字节后才允许重试和同步。
test('unsealed recovery checks recorded bytes before allocating more IDs or releasing writes',async()=>{
const f=fixture(),gate=deferred();let engine=await f.open({digests:{digest(bytes,signal){return new Promise((resolve,reject)=>{signal.addEventListener('abort',()=>reject(Error('aborted')),{once:true});gate.promise.then(()=>importSourceDigest(bytes,signal).then(resolve,reject));});}}});
try{const id=engine.imports.startNdjson(file([node('proof-one'),node('proof-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.pause(id);await engine.persist();const before=engine.imports.getJobs().get(id),ids={...before.stableIds};await engine.close();const bytes=f.files.get(before.source.fileId),text=await bytes.text();f.files.set(before.source.fileId,new Blob([text.replace('proof-two','wrong-two')]));engine=await f.open();await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.source.integrityError,'import_source_changed');assert.equal(f.requests.length,0);assert.deepEqual(job.stableIds,ids);assert.ok(engine.getSnapshot().document.elements.size>0);f.files.set(before.source.fileId,bytes);await engine.imports.resume(id);await engine.settle();assert.equal(f.server().elements.size,2);}finally{gate.resolve();await engine.close();}
});
// 源摘要、读取证明及完整性版本由引擎拥有,公共 updateRuntime 不能伪造证据或清空 committed 进度;拒绝后 root 不变。
test('source evidence is engine-owned and cannot be forged through public runtime updates',async()=>{
const f=fixture(),engine=await f.open();try{const id=engine.imports.startNdjson(file([node('owned-proof')]),'canvas');await engine.settle();const before=engine.getSnapshot(),job=engine.imports.getJobs().get(id);for(const source of [{...job.source,sha256:'0'.repeat(64)},{...job.source,readProof:{...job.source.readProof,blocks:[]}},{...job.source,integrityVersion:1,readProof:undefined}]){const result=engine.editor.updateRuntime(runtime=>({...runtime,imports:new Map(runtime.imports).set(id,{...job,source})}));assert.equal(result.status,'rejected');assert.equal(engine.getSnapshot(),before);}const forged=engine.editor.updateRuntime(runtime=>({...runtime,imports:new Map(runtime.imports).set(id,{...job,status:'cancelled',committedSourceKeys:[]})}));assert.equal(forged.status,'rejected');assert.equal(engine.getSnapshot(),before);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 取消后已接受前缀仍需验证:engine.retry 可补完失败校验并同步该前缀,但保持 cancelled 且不重新开始插入。
test('retry verifies a cancelled accepted prefix without restarting insertion',async()=>{
const f=fixture(),gate=deferred();let fail=true;const engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;if(fail)throw Error('digest unavailable');return importSourceDigest(bytes,signal);}}});try{const id=engine.imports.startNdjson(file([node('retry-cancel-one'),node('retry-cancel-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.cancel(id);gate.resolve();await engine.settle();const before=engine.imports.getJobs().get(id),count=engine.getSnapshot().document.elements.size;assert.ok(before.source.integrityError);assert.equal(f.requests.length,0);fail=false;await engine.retry();await engine.settle();const after=engine.imports.getJobs().get(id);assert.equal(after.status,'cancelled');assert.equal(after.source.integrityError,undefined);assert.ok(after.source.sha256);assert.deepEqual(after.stableIds,before.stableIds);assert.equal(engine.getSnapshot().document.elements.size,count);assert.equal(f.server().elements.size,count);}finally{gate.resolve();await engine.close();}
});
// 旧数组格式 14 和旧 streaming document 格式 16 的 integrity1 状态仍按 SHA-first 语义读取,分别保留 parser3/5。
test('legacy native array 14 and streaming document 16 retain their SHA-first interpreters',async()=>{
for(const format of ['array','document']){const f=fixture();let engine=await f.open();try{const bytes=format==='array'?new Blob([JSON.stringify([node('legacy-native')])]):new Blob([JSON.stringify({documentFormatVersion:1,projectId:'legacy-source',canvases:[{id:'legacy-source',parent:null}],elements:[{...node('legacy-native').record,ownerCanvasId:'legacy-source'}],tombstones:{}})]),id=format==='array'?engine.imports.startJsonArray(bytes,'canvas'):engine.imports.startDocument(bytes,'canvas');await engine.settle();await engine.close();const image=structuredClone(f.image());image.formatVersion=format==='array'?14:16;const job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.source.integrityVersion=1;delete job.source.readProof;delete job.source.integrityError;const decoded=decodeRecovery(image,registry);assert.equal(decoded.runtime.imports.get(id).source.integrityVersion,1);f.storage.recover=async()=>structuredClone(image);engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).parserVersion,format==='array'?3:5);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}}
});
// 千项真实引擎准备/ready 源在发布监听中异步封存,后续推进不改变捕获字节;准备编码实际让出,重开保留暂停和映射。
test('engine preparation encodes in slices and the ready source retains checkpoint bytes and mappings on reopen', {timeout:20000},async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const rows=Array.from({length:1000},(_,i)=>({...node('live-checkpoint-'+i).record,incarnation:1,ownerCanvasId:'canvas'}));
@@ -414,6 +521,7 @@ test('engine preparation encodes in slices and the ready source retains checkpoi
const id=await engine.imports.duplicate(rows.map(row=>({id:row.id,incarnation:1})));
await until(engine,snapshot=>snapshot.runtime.imports.get(id)?.source?.kind==='duplicate-plan');
await engine.imports.pause(id);unsubscribe();
// 等待发布监听里启动的封存全部完成,检查准备与源两个阶段都被捕获,且编码期间后续变更未改写旧值。
const results=await Promise.all(captured);assert.deepEqual([...seen].sort(),['preparation','source']);
for(const result of results){assert.equal(result.error,undefined);if(result.phase==='preparation')assert.ok(result.yields>0,'preparation checkpoint was synchronously normalized');assert.equal(JSON.stringify(result.image.payload),result.before);}
const ids=engine.imports.getJobs().get(id).stableIds;gate.resolve();await engine.close();engine=await f.open();