/** * 旧快照到 canonical 身份迁移的持久请求状态机测试。先 drain 旧写、读取最新源版本、保存冻结 body,再派发迁移。 * 未知结果保留原 ID/字节,恢复先查询后按原请求重试;只有验证过的终态才可清理恢复材料或放开旧编辑。 * 内存存储与假服务端注入响应丢失、版本冲突及配额问题;真实 UI/Web Locks 场景由 renderer 集成探针另测。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts'; // 固定 UUID 作用域及新元素身份;源旧 ID 为 a,成功迁移须返回声明映射到新元素的证明。 const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents()); // 保存迁移字符串、回执、冻结 body 和请求记录,开关区分提交前失败、提交后丢 ACK、明确拒绝和篡改响应。 function fixture(){ const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[]; const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100; // 模拟 localStorage;quota 在 setItem 前抛错,removeItem 真正删除恢复记录。 const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)}; // 完整 canonical 模型与新身份 id(20),供成功回执的版本、元素类型和 identity_map 交叉校验。 const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]}); // GET 分别查迁移需求/完整模型和原回执;POST 先断言已持久化同一 body,再按 requestId 防止重复应用。 const transport=async input=>{ calls.push(input); if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}}; if(input.method==='GET'){ const key=input.path.split('/').at(-1),known=receipts.get(key); const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}}; return f.alter?f.alter(response,input):response; } // 网络派发前直接读取保存字符串,对照请求体,证明持久化屏障而非只检查内存中存在请求。 assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST'); assert.equal(input.responseFormat,'canvas-v2'); // 提交前失败没有服务端回执;恢复只能查询 unknown 后精确重试,不能据传输异常推定 applied。 if(f.failBeforeCommit)throw Error('connection lost before commit'); const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId); // 已知 key 必须使用相同冻结字节;新 key 根据 reject 开关生成可信零写入拒绝或带映射/完整模型的成功。 let response=receipts.get(input.requestId); if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');} else{ response=f.reject?{status:412,body:{protocol_version:2,request_id:input.requestId,status:'not-applied',terminal:true,error:'migration_version_conflict'}}:{status:200,body:{protocol_version:2,request_id:input.requestId,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:root,structure:'node',sourceId:'a',elementId:id(20)}],model:model(f.version+1)}}; receipts.set(input.requestId,response);bodies.set(input.requestId,input.body); if(!f.reject){f.version++;f.canonical=true;} } // 此时服务端事实与回执已保存,再模拟 ACK 丢失;客户端看到错误也不能生成另一次迁移。 if(f.loseResponse)throw Error('connection lost after commit'); return f.alter?f.alter(structuredClone(response),input):response; }; // 组装迁移控制器,默认 lock 只执行 work(不证明真实互斥);current、createId 和 signal 可被用例注入。 f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra}); // 从全部调用中过滤 POST,分别检查旧请求精确重试与明确重新准备产生新请求。 f.posts=()=>calls.filter(call=>call.method==='POST');return f; } // drain 必须在任何读请求前完成,之后重新读源版本;POST 使用 drain 后的版本和预先持久化 requestId,成功后清理记录。 test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{ const f=fixture(),migration=f.make();let drained=false; const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;}); assert.equal(drained,true);assert.equal(result.status,'applied');assert.equal(result.rootId,root); assert.deepEqual(JSON.parse(f.posts()[0].body).expected_versions,{[root]:4});assert.equal(migration.read(),undefined); assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id); }); // 服务提交成功但响应丢失,重建控制器先查原 key 即恢复成功;不再次 drain、不分配新 ID,也不发第二个 POST。 test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{ const f=fixture();f.loseResponse=true;const first=f.make(); assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read(); assert.equal(migrationBlocksCanvas(job,root),true);assert.equal(migrationBlocksCanvas(job,child),false); f.loseResponse=false;const recovered=f.make({createId(){assert.fail('recovery must not allocate a new key');}}); assert.equal((await recovered.start(root,async()=>assert.fail('old writers must remain frozen'))).status,'applied'); assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined); }); // 提交前断线的 durable 请求先 query unknown,再保留原 ID 和包含额外空白的 body 精确重试,不能重序列化。 test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{ const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{}); const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false; const recovered=f.make({createId(){assert.fail('must retain original key');}}); assert.equal((await recovered.resume()).status,'applied'); assert.equal(f.posts()[1].requestId,original.id);assert.equal(f.posts()[1].body,spaced.body); assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined); }); // 存储配额或旧 writer drain 失败均不得发迁移;drain 失败也不能留下新恢复任务或触发网络读取。 test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{ const f=fixture();f.quota=true; await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0); f.quota=false;f.calls.length=0; await assert.rejects(()=>f.make().start(root,async()=>{throw Error('draft_recovery_required');}),/draft_recovery_required/); assert.equal(f.calls.length,0);assert.equal(f.records.size,0); }); // 可信 terminal not-applied 才将 phase 置 rejected 并释放编辑 gate;resume 不重发,显式 start 才刷新版本并生成新 key。 test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{ const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{}); assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false); const posts=f.posts().length;assert.equal((await migration.resume()).status,'rejected');assert.equal(f.posts().length,posts); f.reject=false;f.version=7; assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7}); }); // 裸 403、错 key/源版本/空或错类型/跨 canvas 映射不构成成功证明,保持 pending gate;原回执查询修复后仍只有一次 POST。 test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{ for(const alter of [ response=>({status:403,body:{error:'forbidden'}}), response=>({...response,body:{...response.body,request_id:id(999)}}), response=>({...response,body:{...response.body,source_versions:{[root]:99}}}), response=>({...response,body:{...response.body,identity_map:[]}}), response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:child,structure:'node',sourceId:'a',elementId:id(20)}]}}), response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:root,structure:'connection',sourceId:'a',elementId:id(20)}]}}), ]){ const f=fixture();f.alter=alter;const migration=f.make();assert.equal((await migration.start(root,async()=>{})).status,'pending'); const frozen=migration.read();assert.equal(frozen.phase,'pending');assert.equal(migrationBlocksCanvas(frozen,root),true); f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1); } }); // 未来迁移格式及同 workspace 另一 pending 根不能被覆盖;换账户读取不同存储键,不泄漏旧请求。 test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{ const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'})); const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0); f.storage.removeItem(key);f.failBeforeCommit=true;const migration=f.make();await migration.start(root,async()=>{});const pending=f.storage.getItem(key); await assert.rejects(()=>migration.start(child,async()=>assert.fail()),/workspace_migration_pending/);assert.equal(f.storage.getItem(key),pending); assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined); }); // 响应回来时身份已变或 signal abort,不能清理旧账户恢复材料;回到有效身份后查询原回执,不重复迁移。 test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{ const f=fixture();f.alter=response=>{f.current=false;return response;}; await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1); f.current=true;f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1); const g=fixture(),controller=new AbortController();g.alter=response=>{controller.abort();return response;}; await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal); }); // 同控制器重入 resume 被 migration_busy 拒绝;完成后新控制器识别已 canonical,只返回 already-migrated 不再 POST。 test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{ const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve); const original=f.make(); const calls=f.calls;const migration=f.make({lock:async work=>{entered();await gate;return work();}}); const first=migration.start(root,async()=>{});await started; await assert.rejects(()=>migration.resume(),/migration_busy/);assert.equal(calls.length,0);release();assert.equal((await first).status,'applied'); assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1); }); // 成功后清理记录失败仍保留可恢复请求,重试查询即可;假存储吞掉 setItem 时以 migration_not_durable 拒绝派发。 test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{ const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}}; await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/); assert.equal(f.records.size,1);assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1); const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0); }); // 合法空模型允许 identity_map 为空,不应为了满足校验凭空生成不存在元素的映射。 test('a valid empty migration has no fabricated identity entries',async()=>{ const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}}); assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0); });