import {test} from 'node:test'; import assert from 'node:assert/strict'; import {randomUUID} from 'node:crypto'; import {createDocument,applyChanges,createRegistry,builtinComponents} 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'; const registry=createRegistry(builtinComponents()),root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002'; const scope={accountId:'10000000-0000-4000-8000-000000000003',workspaceId:'10000000-0000-4000-8000-000000000004',projectId:root}; const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}); const a=node('A'),b=node('B'),frame={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null}; const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,[canvases[0]],[a]),full=createDocument(root,canvases,[a,b,frame,c,edge]); const versions=new Map([[root,1],[child,1]]),state={version:1,sourceToken:'captured-read',declaredCanvasIds:[root,child],receivedElements:1,totalElements:5},target={id:'A',incarnation:1}; function fixture(){ let image=null,leader,server=full;const serverVersions=new Map(versions),calls=[],receipts=new Map(),errors=[]; const storage={broken:false,saved:[],async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){},image:()=>structuredClone(image)}; const model=(participants=[...serverVersions.keys()],complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(item=>participants.includes(item.id)),elements:[...server.elements.values()].filter(item=>participants.includes(item.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,serverVersions.get(id)])),removedCanvasIds:[]}); const transport=async input=>{ if(input.method==='GET')return input.path.endsWith('/model')?{status:200,body:{model:model()}}:{status:200,body:receipts.get(input.path.split('/').at(-1))}; calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],serverVersions.get(id)); if(!receipts.has(input.requestId)){server=applyChanges(server,body.operations.flatMap(item=>item.changes));for(const id of participants)serverVersions.set(id,serverVersions.get(id)+1);receipts.set(input.requestId,{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(item=>item.id),model:model(participants,false)});} return{status:200,body:receipts.get(input.requestId)}; }; const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:root,registry,transport}); return{storage,persistence,calls,errors,server:()=>server,advance(changes){server=applyChanges(server,changes);serverVersions.set(root,serverVersions.get(root)+1);},currentVersions:()=>new Map(serverVersions),open:extra=>openProjectEngine({scope,registry,document:prefix,versions,loading:state,storage,persistence,createId:randomUUID,onError:error=>errors.push(error),...extra})}; } test('loaded fields edit and undo/redo durably while no request freezes; full source adds tail and sends each edit once',async()=>{ const f=fixture();let engine=await f.open();try{ assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}}).status,'applied');await engine.settle(); assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().runtime.ledger.requests.size,0);assert.equal(f.storage.image().formatVersion,7);decodeRecovery(f.storage.image(),registry); assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();const history=engine.getSnapshot().history; await engine.close();engine=await f.open();await engine.settle();assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'local');assert.equal(engine.getSnapshot().document.elements.get('A').geometry.x,25); const next=createDocument(root,[canvases[0]],[a,b]);assert.equal(engine.loading.accept(next,versions,{...state,receivedElements:2}).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'local');assert.equal(engine.getSnapshot().document.elements.size,2);assert.deepEqual(engine.getSnapshot().history.undo,history.undo); assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.server().elements.get('A').geometry.x,25);assert.ok(f.calls.length>0);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));assert.equal(f.errors.length,0); }finally{await engine.close();} }); test('incomplete scopes block structure/resources/references and untrusted marker removal without changing the root',async()=>{ const f=fixture(),engine=await f.open();try{ for(const command of [{type:'element.remove',target},{type:'element.create',element:node('new')},{type:'element.move',target,canvasId:child},{type:'element.data',target,patch:{assetId:'asset'}},{type:'element.data',target,patch:{presentation:{scale:2}}}]){const before=engine.getSnapshot();assert.equal(engine.editor.dispatch(command).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);} const before=engine.getSnapshot();assert.equal(engine.editor.updateRuntime(previous=>({...previous,loading:undefined})).code,'loading_state_owned');assert.strictEqual(engine.getSnapshot(),before); assert.throws(()=>engine.stageFiles([{name:'x',bytes:new Blob(['x'])}],()=>{}),{code:'model_loading'});await assert.rejects(engine.imports.duplicate([target]),{code:'model_loading'});assert.equal(f.calls.length,0); }finally{await engine.close();} }); test('full baseline must be durable before the hold releases; quota retry never sends from the partial cut',async()=>{ const f=fixture(),engine=await f.open();try{ engine.editor.dispatch({type:'element.data',target,patch:{title:'saved edit'}});await engine.settle();f.storage.broken=true; await assert.rejects(engine.loading.complete(full,versions,{sourceToken:state.sourceToken}),/quota/);assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);assert.ok(engine.getSnapshot().runtime.ledger.requests.size===0); f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.server().elements.get('A').data.title,'saved edit');assert.ok(f.calls.length>0);assert.notEqual(f.storage.image().formatVersion,7); }finally{f.storage.broken=false;await engine.close();} }); test('late or changed prefix is rejected; a fresh full read preserves local edits and same-version new records',async()=>{ const f=fixture(),engine=await f.open();try{ engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const before=engine.getSnapshot(); assert.equal(engine.loading.accept(prefix,versions,{...state,sourceToken:'another'}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before); const changed=createDocument(root,[canvases[0]],[{...a,data:{...a.data,title:'remote'}}]);assert.equal(engine.loading.accept(changed,versions,state).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before); assert.equal((await engine.loading.complete(full,versions,{fresh:true})).status,'applied');await engine.settle();const done=engine.getSnapshot();assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.strictEqual(engine.getSnapshot(),done); }finally{await engine.close();} }); test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/history without requests',async()=>{ const f=fixture(),engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const image=f.storage.image();assert.equal(decodeRecovery(image,registry).runtime.loading.sourceToken,state.sourceToken); for(const formatVersion of [1,2,3,4,5,6])assert.throws(()=>decodeRecovery({...image,formatVersion},registry),{code:'unsupported_recovery_version'}); const missing=structuredClone(image);delete missing.payload.runtime.loading;assert.throws(()=>decodeRecovery(missing,registry),{code:'invalid_loading_state'}); }finally{await engine.close();} }); test('a fresh conflicting completion keeps the original partial edit and resolves through the existing reviewed recovery path',async()=>{ const f=fixture(),engine=await f.open();try{ const edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle(); f.advance([{kind:'field',target,field:'data.text',before:'original',after:'remote',beforePresent:true,afterPresent:true}]); assert.equal((await engine.loading.complete(f.server(),f.currentVersions(),{fresh:true})).status,'rejected');assert.ok(engine.loading.getState());assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'local');assert.equal(f.calls.length,0); const session=await engine.prepareRecovery();assert.ok(session.conflict);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.errors.length,0); }finally{await engine.close();} }); test('reopening a partial image with a complete fresh model finishes through refresh without replacing history',async()=>{ const f=fixture();let engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{title:'retained'}});await engine.settle();const history=engine.getSnapshot().history;await engine.close();engine=await f.open({document:full,loading:undefined});assert.ok(engine.loading.getState());assert.equal(engine.refresh(full,versions).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'retained');assert.deepEqual(engine.getSnapshot().history.undo,history.undo);}finally{await engine.close();} }); test('permitted command cannot hide a reference write inside a feature handler',async()=>{ const {defineComponent,defineFeature}=await import('../../src/lib/canvas-core/index.ts'); const custom=defineComponent({id:'unsafe-handler',dataVersion:1,structure:'node',validate:()=>true,resourceFields:[],copy:{references:[{path:['link'],target:'element',external:'preserve'}],omit:[]},features:[defineFeature('side-effect',{'content.edit':({element})=>[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.link',before:'old',after:'new',beforePresent:true,afterPresent:true}]})]}); const customRegistry=createRegistry([...builtinComponents(),custom]),record={...a,componentId:custom.id,data:{title:'A',link:'old'}},document=createDocument(root,[canvases[0]],[record]),f=fixture(),engine=await f.open({registry:customRegistry,document}); try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'allowed title'}}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();} }); test('future loading interpreters, changed capture counts and forged frozen requests fail closed on recovery',async()=>{ const f=fixture(),engine=await f.open();try{await engine.persist();const source=f.storage.image(); for(const mutate of [image=>image.payload.runtime.loading.version=2,image=>image.payload.runtime.loading.receivedElements=99,image=>image.payload.runtime.loading.declaredCanvasIds=[],image=>image.payload.runtime.ledger.requests.push(['fake',{}])]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));} }finally{await engine.close();} }); test('fresh full read merges unrelated remote fields and geometry undo does not roll them back',async()=>{ const f=fixture(),engine=await f.open();try{ engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}});await engine.settle();f.advance([{kind:'field',target,field:'data.title',before:'A',after:'remote title',beforePresent:true,afterPresent:true}]); assert.equal((await engine.loading.complete(f.server(),f.currentVersions(),{fresh:true})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('A').data.title,'remote title');assert.equal(f.server().elements.get('A').geometry.x,25); assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').geometry.x,0);assert.equal(f.server().elements.get('A').data.title,'remote title'); }finally{await engine.close();} }); test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{ const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();} }); test('background full reads are shared, preserve edits and cannot downgrade after completion',async()=>{ let release;const gate=new Promise(resolve=>release=resolve);const f=fixture();let reads=0;const persistence={...f.persistence,async read(){reads++;await gate;return{document:f.server(),versions:f.currentVersions()};}},engine=await f.open({persistence}); try{engine.editor.dispatch({type:'element.data',target,patch:{text:'background edit'}});await engine.persist();const first=engine.loading.resume(),second=engine.loading.resume();assert.strictEqual(first,second);while(!reads)await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,1);release();assert.equal((await first).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.text,'background edit');assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.equal((await engine.loading.resume()).status,'applied');assert.equal(reads,1);}finally{release();await engine.close();} }); test('project close aborts a background read and persists its partial edits for the next owner',async()=>{ const f=fixture();let aborted=false,started;const reading=new Promise(resolve=>started=resolve);const persistence={...f.persistence,read:signal=>new Promise((resolve,reject)=>{started();signal.addEventListener('abort',()=>{aborted=true;reject(signal.reason);},{once:true});})};let engine=await f.open({persistence}); engine.editor.dispatch({type:'element.data',target,patch:{text:'survives source close'}});await engine.persist();const rejected=assert.rejects(engine.loading.resume());await reading;await engine.close();await rejected;assert.equal(aborted,true);assert.equal(f.storage.image().formatVersion,7);engine=await f.open();try{assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'survives source close');assert.equal(f.calls.length,0);}finally{await engine.close();} }); test('Workspace opens one partial owner and ignores an older foreground result after background synchronization',async()=>{ const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture();const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:()=>({persistence:f.persistence})}); try{const [first,second]=await Promise.all([workspace.openPartial(prefix,versions,state),workspace.openPartial(prefix,versions,state)]);assert.strictEqual(first,second);first.editor.dispatch({type:'element.data',target,patch:{title:'kept background'}});await first.loading.resume();await first.settle();const before=first.getSnapshot();assert.strictEqual(await workspace.open(full,versions),first);assert.strictEqual(first.getSnapshot(),before);assert.equal(first.getSnapshot().runtime.conflicts.size,0);assert.equal(workspace.getProjects().size,1);}finally{await workspace.close();} }); test('Workspace reports a background source failure while preserving the pending partial cut and fences retired reports',async()=>{ const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})}); try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();} });