Files
Endoleap/tests/headless/applied-recovery.test.mjs
T

83 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 服务端已证明 applied、但本地尚未完成 canonical 接纳时的恢复测试。
* 模拟 codec 暂不可用、新远端编辑、持久化失败及回执过期,验证恢复不会重发已成功操作或提前释放 writer。
* 这里使用内存传输和克隆快照存储,覆盖协议协调,不覆盖真实数据库或浏览器存储实现。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
// 固定 UUID 作用域与元素身份,满足 Foundry adapter 的请求验证要求。
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
// 文本元素及其第一代身份,远端几何修改和本地命令都以这一身份为条件。
const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
// 内置定义与初始文档;fixture 用新的 server 引用演进,保留原始文档作为恢复对照。
const registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[element]);
// 构造可注入 codec、配额和过旧读取故障的服务端/存储环境,记录全部请求用于检查重复写入。
function fixture(){
let server=original,version=1,image,leader;const calls=[],receipts=new Map();
// 把指定文档和版本封装成服务响应;complete=false 表示回执附带的局部模型,而非完整恢复读取。
const model=(document=server,currentVersion=version,complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:{[root]:currentVersion},removedCanvasIds:[]});
// 存储 claim 检查单 leader;commit 在克隆完整 image 前检查配额及 owner,remoteGeometry 则模拟其他客户端提交。
const f={failCodec:true,broken:false,behind:false,calls,receipts,storage:{async claim(scope){assert.equal(leader,undefined);return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},image:()=>structuredClone(image),remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;}};
// GET 分别提供模型与回执;POST 先检查 expected_versions,再应用操作、增加版本并保存可查询回执。
const transport=async request=>{
calls.push(request);if(request.method==='GET'){if(request.path.endsWith('/model'))return{status:200,body:{model:f.behind?model(original,1):model()}};const requestId=request.path.split('/').at(-1),receipt=receipts.get(requestId);return receipt?{status:200,body:receipt}:{status:404,body:{protocol_version:2,request_id:requestId,status:'unknown'}};}
// 此假服务只支持本用例的顺序写入:按请求内操作应用 changes,并以同一 requestId 记录成功证据。
const input=JSON.parse(request.body);assert.equal(input.expected_versions[root],version);for(const operation of input.operations)server=applyChanges(server,operation.changes);version++;
const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:input.operations.map(operation=>operation.id),model:model(server,version,false)};receipts.set(request.requestId,receipt);return{status:200,body:receipt};
};
const base=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
// 只让 canonical 接纳暂时失败;网络提交仍成功,从而停在 applied 但 phase=waiting 的待协调状态。
f.port={...base,canonical(...args){if(f.failCodec)throw Error('codec_temporarily_unavailable');return base.canonical(...args);}};
// 以同一作用域及存储重新打开引擎,使关闭后的恢复确实读取上次保存的 image。
f.open=()=>openProjectEngine({scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage:f.storage,persistence:f.port});
return f;
}
// 先提交一次改名并确认网络成功、本地接纳等待;后续各用例都从这个关键中间状态开始。
async function applied(f){const engine=await f.open();engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');return{engine,request};}
// 远端又修改几何后,恢复以较新的完整读取接纳已成功请求;保留原历史与冻结字节,重开后也不增加 POST。
test('a verified applied request adopts a newer full read without reapplying its old receipt or generating a new write',async()=>{
const f=fixture(),{engine,request}=await applied(f);f.remoteGeometry({x:500,width:300});const history=engine.getSnapshot().history,session=await engine.prepareRecovery();
assert.deepEqual(session.acknowledgedRequestIds,[request.id]);assert.deepEqual(session.pendingOperationIds,[]);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
await session.apply();await engine.settle();const current=engine.getSnapshot();assert.equal(current.history,history);assert.equal(current.document.elements.get('a').data.title,'first');assert.equal(current.document.elements.get('a').geometry.x,500);assert.equal(current.document.elements.get('a').geometry.width,300);
assert.equal(current.runtime.versions.get(root),3);assert.equal(current.runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(current.runtime.ledger.requests.get(request.id).body,request.body);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);
await engine.close();const recovered=await f.open();await recovered.settle();assert.equal(recovered.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await recovered.close();
});
// 把成功确认和显式 keep-local 重规划一起持久化;配额失败不能派发后续写,重试后撤销仍保留远端的新宽度。
test('acknowledgement and explicit replan commit together; original writer releases only after the new cut is durable',async()=>{
const f=fixture(),{engine,request}=await applied(f);const edit=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await engine.settle();f.remoteGeometry({x:500,width:300});
const session=await engine.prepareRecovery();assert.ok(session.conflict);assert.deepEqual(session.pendingOperationIds,edit.operationIds);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));
assert.equal(plan.audit.decisions.length,1);assert.equal(plan.ledger.operations.get(request.operationIds[0]).resolution,'applied');f.failCodec=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);
f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>call.method==='POST').length,2);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.width,300);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,500);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.width,300);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');
assert.equal(decodeRecovery(f.image(),registry).runtime.ledger.requests.get(request.id).body,request.body);await engine.close();
});
// 取消恢复预览应保持整个原快照、waiting 请求和历史不变,不产生第二次 POST。
test('a cancelled acknowledgement preview leaves the applied request pending and its original history untouched',async()=>{
const f=fixture(),{engine,request}=await applied(f),before=engine.getSnapshot(),session=await engine.prepareRecovery();await session.cancel();
assert.equal(engine.getSnapshot(),before);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
});
// 完整读取落后于成功回执时拒绝接纳;回执覆盖错误和缺失在再次读模型前拒绝,不能靠新写入消除疑点。
test('a fresh read behind a proven receipt cannot release its claim; malformed and missing proofs remain preserved',async()=>{
const f=fixture(),{engine,request}=await applied(f);f.behind=true;await assert.rejects(engine.prepareRecovery(),/recovery_read_behind_receipt/);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
f.behind=false;const beforeReads=f.calls.filter(call=>call.path.endsWith('/model')).length;
const real=engine.getSnapshot().runtime.receipts.get(request.id);engine.editor.updateRuntime(runtime=>({...runtime,receipts:new Map([[request.id,{...real,operation_ids:['wrong']} ]])}));await assert.rejects(engine.prepareRecovery(),/receipt_coverage_mismatch/);
assert.equal(f.calls.filter(call=>call.path.endsWith('/model')).length,beforeReads);engine.editor.updateRuntime(runtime=>({...runtime,receipts:new Map()}));await assert.rejects(engine.prepareRecovery(),/recovery_receipt_missing/);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
});
// 关闭并重开后,即使服务端回执已过期,本地持久化成功证据仍可配合完整读取完成接纳,无需重发。
test('a locally durable success proof survives server receipt expiry without resending the original operation',async()=>{
const f=fixture(),{engine,request}=await applied(f);await engine.close();f.receipts.clear();const recovered=await f.open();await recovered.settle();
const session=await recovered.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);await session.apply();await recovered.settle();
assert.equal(recovered.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);assert.equal(recovered.getSnapshot().document.elements.get('a').data.title,'first');await recovered.close();
});