Files
Endoleap/tests/headless/conflicts.test.mjs
T

213 lines
24 KiB
JavaScript

/**
* 显式冲突恢复测试:用户选择 keep-local 或 discard-local 后,基于已审阅的远程基线重建条件操作和撤销链。
* 旧请求及拒绝证据保留在账本与恢复审计中;结果未知的操作不能靠用户选择直接退休,付费动作不能自动重启。
*/
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 { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
// 共享类型定义和目标身份,保证冲突处理仍执行数据校验及 incarnation 校验。
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
// 单元素基线同时包含文字和几何,便于区分局部字段重放与整对象覆盖。
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'canvas',data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
// 独立的初始项目文档;服务替身和新引擎都从此基线开始。
const original=createDocument('project',[{id:'canvas',parent:null}],[element]);
// 推进有限轮微任务,供恢复读取进入挂起点;不等待被故意阻塞的完整恢复会话。
const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
// 默认拒绝写入并允许外部修改服务基线;存储可注入 quota,支持验证恢复计划先发布后落盘失败的重试。
function fixture(){
let server=original,version=1,image,leader;const calls=[];
const f={reject:true,broken:false,calls,storage:{
// 拒绝并行 owner,释放后下一次 open 才能重新接管;这是内存互斥替身。
async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},
// 返回最近已提交镜像的副本,重开复用相同存储但不共享可变镜像对象。
async recover(){return image&&structuredClone(image);},
// 故障发生时不推进镜像;成功时校验存储 owner 并返回实际保存修订。
async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},
// 本组不读取本地文件;空方法仅补齐 StoragePort。
async readFile(){},close(){}},port:{
// 根据当前 ACK 版本编译条件请求,让恢复后的替代意图采用新基线。
compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},
// 默认给出明确 not-applied;放行后先检查版本再改服务状态。此替身不实现未知结果或请求去重。
async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},
// 这里不测试未知结果查询;若恢复误走该路径,立即失败。
async query(){throw Error('unexpected query');},
// 为已知成功请求提供当前服务模型和版本。
canonical(){return{document:server,versions:new Map([['canvas',version]])};},
// 恢复会话读取最新完整服务基线,不读取客户端 working。
async read(){return{document:server,versions:new Map([['canvas',version]])};}},
// 外部写入逐字段保留存在性,模拟其他客户端推进远程版本。
remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},
// 外部几何只改变指定轴,恢复时应保留本地未触碰的那些轴。
remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},
// 直接替换服务模型并推进版本,用来模拟目标删除等生命周期变化。
setServer(document){server=document;version++;},
// 提供恢复镜像副本,供审计篡改测试使用。
image:()=>structuredClone(image),
// 重新装配引擎但保留 fixture 的服务端、回执观测和存储状态。
async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
return f;
}
// 只收集尚未解决的非上传操作,生成恢复选项时不把独立资源上传当作文档编辑。
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
// 为每个待处理操作显式指定选择;某些用例覆盖个别选择以验证依赖闭包和动作限制。
const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice]));
// 通过真实命令入口修改标题,可传 groupId 检查输入历史分组在恢复后如何拆分或保留。
const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings);
// keep-local 以远程字段前值创建新操作和新请求 key,保留旧拒绝;撤销回到审阅过的远程标题,未触碰文字不回退。
test('keep-local freezes a new conditional intent/key, retains the old rejection, and undo restores the reviewed remote value',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'});
const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
assert.equal(engine.getSnapshot(),before);assert.equal(plan.document.elements.get('a').data.title,'local');assert.equal(plan.document.elements.get('a').data.text,'untouched remote');
// keep-local 是基于远程 before 值重新表达意图,不是重发旧请求;旧冻结 body 与 not-applied 证据应继续留存。
const replacement=plan.operations.get(plan.operationIds[0]);assert.notEqual(replacement.id,old.operationIds[0]);assert.equal(replacement.changes[0].before,'remote');
assert.equal(plan.ledger.requests.get(old.id).body,old.body);assert.equal(plan.ledger.operations.get(old.operationIds[0]).resolution,'superseded');
f.reject=false;assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();
assert.equal(f.calls.length,2);assert.notEqual(f.calls[1].id,old.id);assert.equal(f.calls[1].expectedVersions.canvas,2);
const current=engine.getSnapshot();assert.equal(current.runtime.recoveries.size,1);assert.equal(current.runtime.ledger.requests.get(old.id).outcome,'not-applied');
assert.equal(current.history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'untouched remote');
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'local');
const restored=decodeRecovery(f.image(),registry);assert.equal(restored.runtime.recoveries.size,1);assert.equal(restored.runtime.ledger.requests.get(old.id).body,old.body);
await engine.close();
});
// 丢弃中间一步后重连剩余操作依赖和 undo 链;旧历史及恢复前内容仍保存在审计备份。
test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{
const f=fixture(),engine=await f.open();title(engine,'one');await engine.settle();title(engine,'two');title(engine,'three');await engine.settle();f.remote({title:'remote'});
const oldHistory=engine.getSnapshot().history,ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),selected=new Map([[ids[0],'keep-local'],[ids[1],'discard-local'],[ids[2],'keep-local']]),plan=session.plan(selected);
assert.equal(plan.history.undo.length,2);assert.equal(plan.audit.backup.history.undo.length,3);
const last=plan.operations.get(plan.operationIds.at(-1));assert.ok(last.dependencies.includes(plan.operationIds[0]));assert.ok(!last.dependencies.includes(ids[1]));
f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'one');
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote');
assert.equal(oldHistory.undo.length,3);assert.equal(engine.getSnapshot().runtime.recoveries.get(plan.id).backup.document.elements[0].data.title,'three');await engine.close();
});
// 同一输入组中的已确认前缀仍可撤销;若服务端改过同字段,则撤销基点改为审阅的远程值。
test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{
for(const remote of [false,true]){
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
if(remote)f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
assert.equal(plan.history.undo.length,1);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('a').data.title,remote?'remote':'a');await engine.close();
}
});
// 只丢弃输入组的 pending 后缀,不删除已确认前缀的撤销能力。
test('discarding only a pending group suffix retains applied edits and other fields',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,1);
await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');await engine.close();
});
// 局部改宽度时保留远程 x 和 rotation;撤销只恢复宽度,不回退远程几何。
test('geometry recovery changes only locally touched axes and undo retains remote geometry',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();f.remoteGeometry({x:300,rotation:45});
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.deepEqual(plan.document.elements.get('a').geometry,{x:300,y:0,width:200,height:100,rotation:45});
f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(engine.getSnapshot().document.elements.get('a').geometry,{x:300,y:0,width:100,height:100,rotation:45});await engine.close();
});
// 远程已删除的目标只能丢弃本地修改,keep-local 不得暗中复活旧 incarnation;恢复保留墓碑。
test('a deleted target may be discarded but cannot be resurrected by keep-local',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.setServer(applyChanges(original,[{kind:'element',id:'a',before:element,after:null}]));
const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/stale_incarnation/);const plan=session.plan(choices(engine,'discard-local'));
assert.equal(plan.history.undo.length,0);await session.resolve(plan);assert.equal(engine.getSnapshot().document.elements.has('a'),false);assert.equal(engine.getSnapshot().document.tombstones.get('a'),1);await engine.close();
});
// 恢复发布后 quota 失败仍保留同一个计划和发送屏障;不能复制计划对象替代原计划,持久化重试成功后只新增一次写入。
test('recovery publication failure keeps one plan and blocks sends until the exact cut becomes durable',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1);
await assert.rejects(session.apply(),/recovery_already_committed/);await assert.rejects(session.resolve({...plan}),/invalid_recovery_plan/);
// 使用原 plan 对象重试保存,避免同一恢复决策被重复发布;持久屏障解除后才允许替代请求发送。
f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close();
});
// 漏选任何待处理操作都会拒绝计划;撤销产生的 traversal 也能进入恢复规划,取消会话不提交该计划。
test('incomplete choices reject while recorded user/traversal recovery can be planned',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel();
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.ok(undoSession.plan(choices(engine)).history.redo.length===1);await undoSession.cancel();await engine.close();
});
// 只退休依赖链一部分会被拒绝;已 dispatch、结果未知的操作即使补全闭包仍不安全,失败必须保持账本原引用。
test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{
const ledger=createLedger(),intent=(id,dependencies=[])=>({id,operationType:'document.change',operationSchemaVersion:1,sequence:id==='a'?1:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies});
ledger.add(intent('a'),{});ledger.add(intent('b',['a']),{});const before=ledger.getSnapshot();assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'}]),/recovery_dependency_closure/);assert.equal(ledger.getSnapshot(),before);
const request=ledger.freeze({id:'request',operationIds:['a'],canvasIds:['canvas'],expectedVersions:{canvas:1},payload:{}});ledger.markDispatch(request.id);const frozen=ledger.getSnapshot();
assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'},{operationId:'b',resolution:'discarded'}]),/recovery_operation_not_safe/);assert.equal(ledger.getSnapshot(),frozen);
});
// 旧 owner 的几何不能直接搬到新坐标系;pending run.start 必须显式丢弃并另发新命令,恢复不能自动重启付费动作。
test('owner changes reject old geometry; pending paid actions require an explicit discard and never auto-restart',async()=>{
const {planConflictRecovery}=await import('../../src/lib/canvas-runtime/conflicts.ts');
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();
const frame={...element,id:'frame',componentId:'frame'},moved=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}}],[frame,{...element,ownerCanvasId:'child'}]);
assert.throws(()=>planConflictRecovery(engine.getSnapshot(),{document:moved,versions:new Map([['canvas',2],['child',1]])},choices(engine),registry,()=>crypto.randomUUID()),/ownership_conflict/);
const parent=pending(engine)[0].intent.id;engine.editor.updateRuntime(runtime=>{const ledger=createLedger(runtime.ledger);ledger.add({id:'paid-start',operationType:'run.start',operationSchemaVersion:1,sequence:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies:[parent],run:{target,boardId:'canvas',modelId:'model',prompt:'Generate',propagate:false,parameters:{}}},{});return{...runtime,ledger:ledger.getSnapshot()};});
const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_action_requires_new_command/);const selected=choices(engine);selected.set('paid-start','discard-local');const plan=session.plan(selected);
assert.equal(plan.ledger.operations.get('paid-start').resolution,'discarded');assert.ok(plan.operationIds.every(id=>plan.operations.get(id).operationType==='document.change'));await session.cancel();await engine.close();
});
// 已发布计划即使脱离原会话也可完成持久化屏障;重开校验审计、资源 pin、替代操作链接和备份历史,任一篡改都拒绝。
test('recovery audits and replacement links are validated when reopening and the owner can finish a detached durable barrier',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
f.broken=true;await assert.rejects(session.resolve(plan),/quota/);f.broken=false;f.reject=false;await engine.cancelRecovery();await engine.settle();
const image=f.image();assert.equal(decodeRecovery(image,registry).runtime.recoveries.size,1);
for(const mutate of[
image=>image.payload.runtime.recoveries.splice(0),
image=>image.payload.runtime.resourcePins.splice(0),
image=>image.payload.runtime.recoveries[0][1].decisions[0].replacementId='missing',
image=>image.payload.runtime.recoveries[0][1].backup.history.undo.push('missing'),
image=>{const old=image.payload.runtime.recoveries[0][1].decisions[0].operationId;image.payload.runtime.ledger.operations.find(([id])=>id===old)[1].resolution='applied';},
]){const broken=structuredClone(image);mutate(broken);assert.throws(()=>decodeRecovery(broken,registry),/corrupt_recovery_image/);}
await engine.close();
});
// 远程已满足本地目标值时以 satisfied 退休旧操作,不发送新请求或新增 undo,也不把原拒绝改写成服务端成功。
test('a remote value already satisfying the local choice retires the intent without claiming a server write or adding undo',async()=>{
const f=fixture(),engine=await f.open();title(engine,'same');await engine.settle();f.remote({title:'same'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);
assert.equal(f.calls.length,1);assert.equal([...engine.getSnapshot().runtime.ledger.requests.values()][0].outcome,'not-applied');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,1);await engine.close();
});
// 混合输入组丢弃待处理标题后,已确认文字仍可撤销;撤销不得覆盖远程标题。
test('discarding a field in a mixed typing group keeps the unrelated acknowledged field undoable',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target,patch:{text:'acknowledged text'}},{groupId:'mixed'});await engine.settle();f.reject=true;title(engine,'pending title',{groupId:'mixed'});await engine.settle();f.remote({title:'remote title'});
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.history.undo.length,1);await session.resolve(plan);f.reject=false;
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote title');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'');await engine.close();
});
// 取消挂起读取会 abort 且拒绝迟到结果;即使计划全是 discard-local,提交时仍须重新检查当前身份策略。
test('discard-only resolution rechecks current identity and a cancelled read cannot later create a session',async()=>{
const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle();
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel();
const actualRead=f.port.read;f.port.read=async input=>{signal=input;await new Promise(resolve=>{release=resolve;});return actualRead(input);};const preparing=engine.prepareRecovery();await tick();await engine.cancelRecovery();assert.equal(signal.aborted,true);release();await assert.rejects(preparing,/recovery_session_closed/);assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close();
// 全部 discard 仍会改变本地历史/账本,提交时须再次检查策略;不能因没有新文档请求就跳过当前身份检查。
const other=fixture(),guarded=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:other.storage,persistence:other.port,policy:()=>allowed?{enabled:true}:{enabled:false,code:'identity_changed',targets:[]}});
title(guarded,'local');await guarded.settle();const review=await guarded.prepareRecovery(),discard=review.plan(choices(guarded,'discard-local'));allowed=false;assert.equal((await review.resolve(discard)).code,'identity_changed');assert.equal(guarded.getSnapshot().runtime.recoveries.size,0);await review.cancel();await guarded.close();
});
// 较早拒绝编辑的 keep/discard 不丢失后来本地抵消的创建删除历史;恢复保留本地墓碑,撤销用新 incarnation,不把抵消操作塞进请求。
test('later locally elided history survives keep/discard of an earlier rejected edit and its recovery replay audit',async()=>{
for(const keep of [false,true]){
const f=fixture();let engine=await f.open();
try{
title(engine,'pending');await engine.settle();const temporary={...element,id:'temporary'};
const created=engine.editor.dispatch({type:'element.create',element:temporary}),removed=engine.editor.dispatch({type:'element.remove',target:{id:temporary.id,incarnation:1}});await engine.settle();
const localIds=[...created.operationIds,...removed.operationIds];assert.equal(f.calls.length,1);for(const id of localIds)assert.equal(engine.getSnapshot().runtime.ledger.operations.get(id).resolution,'elided');
f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,keep?'keep-local':'discard-local'));assert.equal(plan.document.tombstones.get(temporary.id),1);f.reject=false;await session.resolve(plan);await engine.settle();
await engine.close();engine=await f.open();assert.equal(engine.getSnapshot().document.tombstones.get(temporary.id),1);assert.equal(engine.getSnapshot().runtime.acknowledged.tombstones.has(temporary.id),false);assert.equal(engine.getSnapshot().history.undo.length,keep?3:2);
// localIds 已被本地抵消但仍有撤销历史;逐请求检查它们未被重新塞入网络同步,同时重开后墓碑仍有效。
for(const request of engine.getSnapshot().runtime.ledger.requests.values())assert.equal(request.operationIds.some(id=>localIds.includes(id)),false);
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(temporary.id).incarnation,2);
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(temporary.id),false);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,keep?'pending':'remote');
decodeRecovery(f.image(),registry);
}finally{await engine.close();}
}
});