101 lines
9.7 KiB
JavaScript
101 lines
9.7 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { createDocument, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
|
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
|
import { createFoundryPersistence, createFoundryTransport, loadFoundryModel } from '../../src/lib/canvas-service/foundry.ts';
|
|
|
|
const root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
|
|
const operationId='90000000-0000-4000-8000-000000000001',requestId='90000000-0000-4000-8000-000000000002';
|
|
const registry=createRegistry(builtinComponents());
|
|
const node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'preserved'},geometry:{x:0,y:0,width:260,height:200,rotation:0}});
|
|
const a=node('a'),f={...node('f'),componentId:'frame'},future={...node('future',child),componentId:'plugin.future',dataVersion:9,data:{custom:{keep:[1,'x']}},extension:{retained:true}};
|
|
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}];
|
|
const document=createDocument(root,canvases,[a,f,future]);
|
|
const snapshot={revision:1,rootRevision:1,document,history:{undo:[],redo:[],entries:new Map()},operations:new Map(),runtime:{acknowledged:document,versions:new Map([[root,3],[child,7]]),ledger:createLedger().getSnapshot(),receipts:new Map(),conflicts:new Map()}};
|
|
const intent={id:operationId,operationType:'document.change',operationSchemaVersion:1,sequence:1,origin:'user',canvasIds:[root],dependencies:[],changes:[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'renamed',beforePresent:true,afterPresent:true}]};
|
|
const chunk=(complete=true)=>({documentFormatVersion:1,projectId:root,canvases:complete?canvases:[canvases[0]],elements:complete?[a,f,future]:[{...a,data:{...a.data,title:'renamed'}},f],tombstones:{},versions:complete?{[root]:3,[child]:7}:{[root]:4},complete,removedCanvasIds:[]});
|
|
function setup(transport=async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}})){
|
|
const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
|
|
const compiled=port.compile(intent,requestId,snapshot),ledger=createLedger();ledger.add(intent,{a:'exists'});
|
|
const request=ledger.freeze({id:requestId,operationIds:[operationId],...compiled});return{port,request,compiled};
|
|
}
|
|
|
|
test('initial model read is explicit about migration/coverage and owns opaque records',async()=>{
|
|
const calls=[];
|
|
const required=await loadFoundryModel(async request=>{calls.push(request);return{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:1}}}},workspace,root,registry);
|
|
assert.equal(required.status,'migration-required');assert.equal(required.sourceVersions.get(root),1);assert.equal(calls[0].method,'GET');
|
|
const source=chunk(),loaded=await loadFoundryModel(async()=>({status:200,body:{model:source}}),workspace,child,registry);
|
|
assert.equal(loaded.document.projectId,root);assert.deepEqual(loaded.document.elements.get('future'),future);
|
|
assert.equal(Object.isFrozen(source.elements[0]),false);source.elements[0]={...a,data:{title:'caller mutation',text:''}};
|
|
assert.equal(loaded.document.elements.get('a').data.title,'a');
|
|
await assert.rejects(()=>loadFoundryModel(async()=>({status:200,body:{model:chunk(false)}}),workspace,root,registry),/incomplete_initial_model/);
|
|
});
|
|
|
|
test('compiler freezes protocol 2 conditional changes and transports the exact original bytes',async()=>{
|
|
const calls=[],{port,request,compiled}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}}});
|
|
assert.equal(compiled.protocolVersion,2);assert.equal(request.protocolVersion,2);
|
|
const body=JSON.parse(request.body);assert.deepEqual(body.operations,[{id:operationId,changes:intent.changes}]);assert.equal(Object.hasOwn(body,'document'),false);
|
|
assert.deepEqual(body,JSON.parse(await readFile(new URL('./fixtures/foundry-model-operation-v2.json',import.meta.url),'utf8')));
|
|
const originalBytes=' \n'+request.body+' ',frozen={...request,body:originalBytes};
|
|
assert.equal((await port.submit(frozen,new AbortController().signal)).outcome,'applied');
|
|
assert.equal(calls[0].body,originalBytes);assert.equal(calls[0].requestId,requestId);
|
|
await port.query(frozen,new AbortController().signal);assert.equal(calls[1].method,'GET');assert.equal(calls[1].body,undefined);assert.ok(calls[1].path.endsWith('/'+requestId));
|
|
});
|
|
|
|
test('only explicit identity-matched terminal proof is zero-write; bare 4xx stays unresolved and blocked',async()=>{
|
|
for(const [response,outcome,blocked]of[
|
|
[{status:409,body:{protocol_version:2,request_id:requestId,status:'not-applied',terminal:true,error:'version_conflict'}},'not-applied',false],
|
|
[{status:409,body:{error:'idempotency_conflict'}},'unresolved',true],
|
|
[{status:403,body:{error:'forbidden'}},'unresolved',true],
|
|
[{status:404,body:{status:'unknown',request_id:requestId}},'unresolved',false],
|
|
]){
|
|
const{port,request}=setup(async()=>response);const status=await port.query(request,new AbortController().signal);
|
|
assert.equal(status.outcome,outcome);assert.equal(!!status.blocked,blocked);
|
|
}
|
|
});
|
|
|
|
test('partial canonical receipts replace participants and retain unrelated future components and versions',async()=>{
|
|
const{port,request}=setup();const status=await port.submit(request,new AbortController().signal),proposal=port.canonical(request,status,snapshot);
|
|
assert.equal(proposal.document.elements.get('a').data.title,'renamed');assert.deepEqual(proposal.document.elements.get('future'),future);assert.equal(proposal.versions.get(child),7);assert.equal(proposal.versions.get(root),4);
|
|
const broken=structuredClone(status);broken.canonical.model.versions={[root]:4,[child]:8};
|
|
assert.throws(()=>port.canonical(request,broken,snapshot),/invalid_canonical_response|receipt_coverage_mismatch/);
|
|
const wrong=structuredClone(status);wrong.canonical.model.elements.push({...future,ownerCanvasId:root});
|
|
assert.throws(()=>port.canonical(request,wrong,snapshot),/canonical_scope_mismatch/);
|
|
assert.equal(snapshot.runtime.acknowledged.elements.get('a').data.title,'a');
|
|
});
|
|
|
|
test('missing logical coverage and altered frozen metadata do not masquerade as successful requests',async()=>{
|
|
const calls=[],{port,request}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[],model:chunk(false)}}});
|
|
const incomplete=await port.submit(request,new AbortController().signal);assert.equal(incomplete.outcome,'unresolved');assert.equal(incomplete.blocked,true);assert.equal(incomplete.error,'receipt_coverage_mismatch');
|
|
const corrupt=await port.submit({...request,expectedVersions:{[root]:99}},new AbortController().signal);
|
|
assert.equal(corrupt.blocked,true);assert.equal(calls.length,1);assert.equal(corrupt.error,'frozen_request_contract_mismatch');
|
|
});
|
|
|
|
test('new canvases carry an explicit absent version without confusing format/protocol/document clocks',()=>{
|
|
const canvas='10000000-0000-4000-8000-000000000005',{port}=setup();
|
|
const create={...intent,canvasIds:[root,canvas],changes:[{kind:'canvas',id:canvas,before:null,after:{id:canvas,parent:{canvasId:root,entryElementId:'f'}}}]};
|
|
const compiled=port.compile(create,requestId,snapshot);assert.equal(compiled.expectedVersions[canvas],0);assert.equal(compiled.expectedVersions[root],3);assert.equal(compiled.protocolVersion,2);
|
|
const ledger=createLedger();ledger.add(create,{});assert.equal(ledger.freeze({id:requestId,operationIds:[operationId],...compiled}).expectedVersions[canvas],0);
|
|
});
|
|
|
|
test('browser transport fences identity changes and never rebuilds a frozen body',async()=>{
|
|
let current=true;const calls=[];
|
|
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async(url,init)=>{calls.push([url,init]);return new Response('{"status":"unknown"}',{status:404,headers:{'Content-Type':'application/json'}})}});
|
|
await transport({path:'fixture',method:'POST',requestId,body:' {"literal":"bytes"} '});
|
|
assert.equal(calls[0][1].headers['X-Expected-Account'],account);assert.equal(calls[0][1].body,' {"literal":"bytes"} ');assert.equal(calls[0][1].headers['Idempotency-Key'],requestId);
|
|
current=false;await assert.rejects(()=>transport({path:'fixture',method:'GET'}),/identity_changed/);assert.equal(calls.length,1);
|
|
const late=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>{current=false;return new Response('{}')}});current=true;
|
|
await assert.rejects(()=>late({path:'fixture',method:'GET'}),/identity_changed/);
|
|
});
|
|
|
|
|
|
test('shared service transport binds the immutable AI admission version header',async()=>{
|
|
const calls=[];
|
|
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>true,fetch:async(url,input)=>{calls.push({url,input});return new Response('{}',{status:200,headers:{'Content-Type':'application/json'}})}});
|
|
await transport({method:'POST',path:'fixture',body:' {"frozen": true} ',requestId,expectedVersion:7});
|
|
assert.equal(calls[0].input.headers['If-Match'],'"7"');assert.equal(calls[0].input.headers['Idempotency-Key'],requestId);assert.equal(calls[0].input.body,' {"frozen": true} ');
|
|
for(const value of [0,-1,1.5,NaN,Number.MAX_SAFE_INTEGER+1])await assert.rejects(()=>transport({method:'POST',path:'fixture',expectedVersion:value}),/invalid_server_version/);
|
|
assert.equal(calls.length,1);
|
|
});
|