/** * Foundry 文档适配器的编译、回执和传输契约测试。真实适配器连接可控 transport/fetch 替身,不访问远程 HTTP 或数据库。 * 区分协议版本、文档格式、服务版本向量与元素 incarnation;HTTP 成功、未知结果和明确零写入必须有各自证据。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { createDocument, createRegistry, builtinComponents, applyChanges } 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'; // 固定账户/workspace/根和子 canvas,便于检查局部回执覆盖范围与身份头。 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'; // 逻辑操作 ID 与冻结请求 ID 分开:批次可覆盖多个逻辑操作,但查询和重发仍使用原 requestId。 const operationId='90000000-0000-4000-8000-000000000001',requestId='90000000-0000-4000-8000-000000000002'; // 用内置类型校验已知记录,未知插件记录仍需不透明保留。 const registry=createRegistry(builtinComponents()); // 构造带完整几何与第一代身份的文字节点,默认属于根 canvas。 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}}); // 子 canvas 中放入未来插件数据和额外 extension,验证局部合并不丢失无关记录。 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}}; // frame f 是子 canvas 的入口,建立文档归属树而非仅两个独立列表。 const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}]; // 初始业务文档保留原始标题,供 ACK 合并后的结果对比。 const document=createDocument(root,canvases,[a,f,future]); // ACK 基线版本为根 3、子 7;历史和账本为空,后续冻结从这一确定基线读取参与版本。 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()}}; // 单一条件字段修改,带 before/after 和存在性;编译器只负责转成增量协议,不发送整份文档。 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}]}; // complete 模型用于初始/恢复读取;partial 模型只覆盖根并将版本推进到 4,子内容不在该回执内。 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:[]}); // 建立适配器、编译意图并由真实 ledger 冻结请求;默认 transport 返回单操作成功和根 canvas 局部模型。 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}; } // 初始读显式返回 migration-required;从子入口读取仍得到根项目并持有独立模型,未知数据保留,局部模型不能冒充完整初始读。 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/); }); // 冻结协议 2 的条件 changes 并与 JSON 样例比较;带额外空白的原 body 按字节发送,query 为无 body 的原 ID GET。 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)); }); // 只有原 requestId 匹配的 terminal not-applied 是零写入;普通冲突/权限错误保持未知并阻塞,404 unknown 也不证明未执行。 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); } }); // 局部 ACK 只替换参与根 canvas,保留未来子记录与版本 7;额外版本覆盖或冒用其他 owner 的记录均拒绝,输入 ACK 不变。 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'); }); // 缺失逻辑 operation 覆盖的成功响应保持 unresolved;冻结元数据与 body 不一致时直接阻止发送。 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'); }); // 新 canvas 的 absent 版本显式使用 0,已有根仍为 3;这不改变协议版本,也不是元素或文档格式版本。 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); }); // fetch 前后都检查当前身份;冻结 body、幂等键和预期账户头原样传递,身份已变时不发请求,途中变化也拒绝响应。 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/); }); // expectedVersion 编码成不可变 If-Match;零、负数、非整数、NaN 和不安全整数在进入 fetch 前被拒绝。 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); }); // 恢复读取只接受绑定项目的完整规范模型,转发同一 AbortSignal;迁移需求、局部模型、其他项目及权限错误均拒绝。 test('recovery reader requires a complete canonical model for its bound project and forwards cancellation',async()=>{ const controller=new AbortController(),calls=[]; const{port}=setup(async request=>{calls.push(request);return{status:200,body:{model:chunk()}};}); const loaded=await port.read(controller.signal);assert.equal(loaded.document.projectId,root);assert.equal(loaded.versions.get(child),7); assert.equal(calls[0].method,'GET');assert.equal(calls[0].signal,controller.signal);assert.equal(calls[0].body,undefined);assert.equal(calls[0].requestId,undefined); for(const[response,error]of[ [{status:200,body:{model:chunk(false)}},/incomplete_initial_model/], [{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:1}}},/canonical_migration_required/], [{status:200,body:{model:{documentFormatVersion:1,projectId:child,canvases:[{id:child,parent:null}],elements:[],tombstones:{},versions:{[child]:1},complete:true,removedCanvasIds:[]}}},/project_mismatch/], [{status:403,body:{error:'forbidden'}},/forbidden/], ]){const{port:invalid}=setup(async()=>response);await assert.rejects(invalid.read(controller.signal),error);} }); // 历史成功证明绑定冻结契约,允许后来更高版本,但同版本分歧、落后读取、身份/覆盖篡改和墓碑回退均拒绝。 test('historical applied proof validates frozen identity and coverage, retains newer reads and rejects same-version divergence',async()=>{ const{port,request}=setup(),status=await port.submit(request,new AbortController().signal),proof=port.verifyApplied(request,status.canonical),proposal=port.canonical(request,status,snapshot); proof.assertCanonical(proposal);assert.equal(proof.versions.get(root),4);assert.equal(proof.versions.set,undefined); const changed=createDocument(root,canvases,[{...a,data:{...a.data,title:'later remote'}},f,future]); proof.assertCanonical({document:changed,versions:new Map([[root,5],[child,7]])}); assert.throws(()=>proof.assertCanonical({document:changed,versions:new Map([[root,4],[child,7]])}),/canonical_version_mismatch/); assert.throws(()=>proof.assertCanonical({document,versions:new Map([[root,3],[child,7]])}),/recovery_read_behind_receipt/); for(const mutate of[ body=>body.request_id=account,body=>body.operation_ids=[account],body=>body.protocol_version=99,body=>body.status='accepted',body=>body.model.complete=true,body=>body.model.versions[root]=5, ]){const body=structuredClone(status.canonical);mutate(body);assert.throws(()=>port.verifyApplied(request,body),/receipt|canonical/);} const tombstone=structuredClone(status.canonical);tombstone.model.tombstones={deleted:2};const lifecycle=port.verifyApplied(request,tombstone); assert.throws(()=>lifecycle.assertCanonical({document:changed,versions:new Map([[root,5],[child,7]])}),/stale_canonical_lifecycle/); }); // 批量编译保留操作次序、每个逻辑 ID 和全部参与版本;回执遗漏任何操作均不算成功,乱序或重复逻辑 ID 无法编译。 test('ordered batch freezes every logical identity, conditional change and participant version, requiring complete receipt coverage', async () => { const secondId='90000000-0000-4000-8000-000000000003'; const second={...intent,id:secondId,sequence:2,dependencies:[intent.id],changes:[{...intent.changes[0],before:'renamed',after:'final'}]}; const calls=[],port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[intent.id,second.id],model:chunk(false)}}}}); const compiled=port.compileBatch([intent,second],requestId,snapshot),ledger=createLedger();ledger.add(intent,{a:'exists'});ledger.add(second,{a:'exists'}); const request=ledger.freeze({...compiled,id:requestId,operationIds:[intent.id,second.id]}); assert.deepEqual(JSON.parse(request.body).operations,[intent,second].map(({id,changes})=>({id,changes}))); assert.deepEqual(request.expectedVersions,{[root]:3});assert.equal((await port.submit(request,new AbortController().signal)).outcome,'applied');assert.equal(calls[0].body,request.body); const invalid=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[second.id],model:chunk(false)}})}); assert.equal((await invalid.query(request,new AbortController().signal)).error,'receipt_coverage_mismatch'); assert.throws(()=>port.compileBatch([second,intent],requestId,snapshot),/invalid_operation_order/); assert.throws(()=>port.compileBatch([intent,intent],requestId,snapshot),/operation_interpreter_unavailable/); }); // 只在有账本压缩依据时减少 wire changes;最终文档仍到 final,原意图中的中间值不变,回执仍覆盖所有逻辑 ID。 test('Foundry compiles the proven projection without changing logical intents or receipt coverage',async()=>{ const middle='temporary '.repeat(100),secondId='90000000-0000-4000-8000-000000000003'; const first={...intent,changes:[{...intent.changes[0],after:middle}]},second={...intent,id:secondId,sequence:2,dependencies:[first.id],changes:[{...intent.changes[0],before:middle,after:'final'}]},ledger=createLedger(); ledger.add(first,{a:'exists'});ledger.add(second,{a:'exists'}); const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[first.id,second.id],model:chunk(false)}})}); const compiled=port.compileBatch([first,second],requestId,{...snapshot,runtime:{...snapshot.runtime,ledger:ledger.getSnapshot()}}),request=ledger.freeze({...compiled,id:requestId,operationIds:[first.id,second.id]}),wire=JSON.parse(request.body).operations; assert.ok(JSON.stringify(wire).length({id,changes}))).length); assert.equal(applyChanges(document,wire.flatMap(op=>op.changes)).elements.get('a').data.title,'final'); assert.equal(ledger.getSnapshot().operations.get(first.id).intent.changes[0].after,middle); assert.deepEqual(wire.map(op=>op.id),request.operationIds);assert.equal((await port.submit(request,new AbortController().signal)).outcome,'applied'); }); // 摘要为显式可选策略,仅 resolved/applied 请求可压缩;摘要保留执行元数据但不是原始成功证明,未来摘要版本单独拒绝。 test('Foundry receipt summaries are opt-in, retain execution metadata and cannot masquerade as raw recovery proofs',()=>{ const{request,port:rawPort}=setup();assert.equal(rawPort.compactResolvedReceipt,undefined);const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>{},resolvedReceiptPolicy:'summary'}),receipt={protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)},applied=port.canonical(request,{outcome:'applied',canonical:receipt},snapshot),resolved={...request,phase:'resolved',outcome:'applied'},summary=port.compactResolvedReceipt(resolved,receipt,applied);assert.strictEqual(port.compactResolvedReceipt(request,receipt,applied),receipt);assert.deepEqual(summary,{localReceiptFormat:'foundry.reconciled-document',localReceiptVersion:1,protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],projectId:root,versions:{[root]:4}});assert.equal(Object.hasOwn(summary,'model'),false);assert.equal(receipt.model.elements.length,2);assert.throws(()=>port.verifyApplied(request,summary),/receipt_already_reconciled/);assert.throws(()=>port.canonical(request,{outcome:'applied',canonical:summary},snapshot),/receipt_already_reconciled/);assert.throws(()=>port.verifyApplied(request,{...summary,localReceiptVersion:2}),/receipt_summary_interpreter_unavailable/);assert.deepEqual([...port.verifyApplied(request,receipt).versions],[[root,4]]); }); // 遇到未知扩展、未来协议/文档或额外字段保留原回执引用;run.apply 不走文档摘要策略,非法策略配置立即失败。 test('summary policy preserves opaque, future and unfamiliar receipt data unchanged',()=>{ const{request}=setup(),port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>{},resolvedReceiptPolicy:'summary'}),raw={protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)},applied=port.canonical(request,{outcome:'applied',canonical:raw},snapshot),cases=[{...raw,extension:{keep:true}},{...raw,protocol_version:3},{...raw,model:{...raw.model,documentFormatVersion:2}},{...raw,model:{...raw.model,futureField:'keep'}},{...raw,model:{...raw.model,elements:[...raw.model.elements,{...future,id:'opaque',ownerCanvasId:root}]}},{...raw,model:{...raw.model,elements:[{...a,extension:{keep:true}},f]}}];for(const value of cases)assert.strictEqual(port.compactResolvedReceipt({...request,phase:'resolved',outcome:'applied'},value,applied),value);assert.strictEqual(port.compactResolvedReceipt({...request,kind:'run.apply'},raw,applied),raw);assert.throws(()=>createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>{},resolvedReceiptPolicy:'invalid'}),/invalid_receipt_policy/); });