155 lines
20 KiB
JavaScript
155 lines
20 KiB
JavaScript
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';
|
|
|
|
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();};
|
|
function fixture(){
|
|
let server=original,version=1,image,leader;const calls=[];
|
|
const f={reject:true,broken:false,calls,storage:{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);},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(){}},port:{compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},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]])};},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),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]));
|
|
const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings);
|
|
|
|
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');
|
|
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();
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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/);
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
// The policy is evaluated again even when all chosen operations will be discarded.
|
|
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();
|
|
});
|
|
|
|
|
|
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);
|
|
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();}
|
|
}
|
|
});
|