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

196 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
// 严格 JSON 夹具的旁注:protocol_version=2 是请求协议,不是组件 dataVersion 或文档格式版本。
// request_id 使用本测试冻结请求 ID;expected_versions 只含参与根 canvas 的已确认版本 3。
// operations[0].id 是逻辑操作 ID;target.incarnation=1 防止相同元素 ID 的新生命期接收旧修改。
// changes 是 data.title 从 a 到 renamed 的条件修改;beforePresent/afterPresent 显式区分缺失字段和有值字段。
// 深比较覆盖整个编译请求形状;这个静态样例本身不证明真实后端已接受或持久化请求。
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<JSON.stringify([first,second].map(({id,changes})=>({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/);
});