docs(tests): explain service contracts proxy and packaged integration consumers

This commit is contained in:
2026-11-12 11:01:48 +08:00
parent bd4f1a34a0
commit 9d788e1712
5 changed files with 198 additions and 27 deletions
+84 -17
View File
@@ -1,5 +1,7 @@
/** Invoked by Foundry's isolated PostgreSQL/HTTP integration test on its CI runner.
* No mocked model writes: only the recovery store and lost-response fault are local.
/**
* 由 Foundry CI runner 的隔离 PostgreSQL/HTTP 集成环境调用。文档、迁移和资产写入使用真实服务;本地恢复存储及丢响应是测试替身。
* 执行前必须注入隔离服务身份与测试数据;本文件不启动数据库,也不在没有 fixture 时降级为本地服务。
* 依次覆盖迁移、未知回执恢复、分区复制/解散、增量加载期间导入与上传,以及跨项目资源采用;AI 场景由环境开关追加。
*/
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
@@ -12,98 +14,163 @@ import {createFoundryRuns} from '../../src/lib/canvas-service/runs.ts';
import {createCanvasMigration} from '../../src/lib/canvas-migration.ts';
import {verifyFoundryAI} from './foundry-ai.integration.mjs';
import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts';
// 从 runner 环境取得隔离服务及认证参数,仅在进程内供请求使用;缺少任一必需参数直接失败。
const {FOUNDRY_HTTP_URL:url,FOUNDRY_TEST_PROJECT:project,FOUNDRY_TEST_ACCOUNT:accountId,FOUNDRY_TEST_WORKSPACE:workspaceId,FOUNDRY_TEST_ROOT:rootId,FOUNDRY_TEST_TOKEN:token}=process.env;
assert.ok(url&&project&&accountId&&workspaceId&&rootId&&token,'requires isolated Foundry HTTP fixture');
// 装配实际模型校验与项目 API 根路径,calls 只记录测试需要的协议信息。
const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[];
// 各故障开关只在相应成功响应已经返回后生效,不把发送前失败伪装成“服务已提交但响应丢失”。
let loseNextAck=false,loseNextAssetAck=false,loseNextAIAdmission=false,loseNextAIAction=false;
// 只向 fixture 同源地址添加 Bearer 与预期账户头,防止测试资源读取把凭据发送到其他 origin。
const authenticatedFetch=async(resource,init={})=>{assert.equal(new URL(resource).origin,new URL(url).origin);const headers=new Headers(init.headers);headers.set('Authorization',`Bearer ${token}`);headers.set('X-Expected-Account',accountId);return fetch(resource,{...init,headers});};
// 复用生产 HTTP 传输适配器;测试身份固定,身份变化的校验由单元用例另行覆盖。
const rawTransport=createFoundryTransport({basePath,accountId,isCurrentIdentity:()=>true,fetch:authenticatedFetch});
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/')||input.path.endsWith('/canvas/model/operations'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;};
// 记录请求与有限响应摘要,再按动作类型丢弃一次已成功的响应;未命中开关时返回真实结果。
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/')||input.path.endsWith('/canvas/model/operations'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};
// AI 受理仅在 202 时丢响应,确保服务真正创建过任务;重新开关由后续场景显式控制。
if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}
// 取消或应用只拦截成功 POST;故障发生后本地要通过动作回执恢复,不重跑生成。
if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}
// 上传遇到任意 2xx 后才注入丢响应,不能把真实拒绝当作待确认上传。
if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}
// 通用写入故障只拦截一次成功 POST,服务端副作用和回执已经存在。
if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}
// 其余请求完整返回真实传输结果,不修改协议 body 或 stream。
return result;};
// 原项目操作以 runner 准备的 material 为规范项目作用域;跨项目场景稍后另建 root。
const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`;
// fixture 初始必须是待迁移旧画布;这保证随后恢复的是迁移写入而不是现成 canonical 模型。
const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required');
// 用 Map 模拟迁移本地日志;不在此场景验证浏览器存储或真实跨标签页锁。
const migrationRecords=new Map(),migrationStorage={getItem:key=>migrationRecords.get(key)??null,setItem:(key,value)=>migrationRecords.set(key,value),removeItem:key=>migrationRecords.delete(key)};
// 迁移协调器使用真实 transport 与随机请求 ID,锁替身直接执行 work,专注幂等迁移协议。
const migrationOptions={scope:{accountId,workspaceId},registry,transport,storage:migrationStorage,isCurrentIdentity:()=>true,createId:randomUUID,lock:async work=>work()};
// 真实迁移成功后丢响应,本地仍为 pending;新协调器恢复时禁止再分配 ID,只查询原受理。
loseNextAck=true;const admission=createCanvasMigration(migrationOptions);assert.equal((await admission.start(rootId,async()=>{})).status,'pending');assert.equal(loseNextAck,false);
// 保存原迁移 ID 和 body,并统计 POST 次数;恢复不得重新发送迁移。
const frozenMigration=admission.read(),migrationId=frozenMigration.id,migrationBody=frozenMigration.body,migrationPosts=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length;
assert.equal((await createCanvasMigration({...migrationOptions,createId:()=>assert.fail('recovery allocated a new migration identity')}).resume()).status,'applied');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length,migrationPosts);assert.equal(migrationRecords.size,0);
// 直接查询原迁移回执,再以同 ID/body 重试并比较 identity_map,检验真实服务的幂等迁移结果。
const migrated=await transport({method:'GET',path:path+'/model/operations/'+migrationId});assert.equal(migrated.status,200);assert.equal(migrated.body.status,'applied');
const retried=await transport({method:'POST',path:path+'/model/migrate',body:migrationBody,requestId:migrationId});assert.deepEqual(retried.body.identity_map,migrated.body.identity_map);
// 迁移完成后完整读取规范模型;后续引擎以此作为初始 ACK 基线。
let initial=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(initial.status,'ready');
// 隔离 fixture 必须含 frame 和子 canvas 成员,保证后续有真实嵌套结构可操作。
const sourceFrame=[...initial.document.elements.values()].find(element=>element.componentId==='frame'),sourceMember=[...initial.document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId!==rootId);assert.ok(sourceFrame&&sourceMember);
// 本地恢复镜像、文件仓库和时钟在引擎关闭后保留,views 不参与持久化。
const scope={accountId,workspaceId,projectId:rootId},files=new Map(),errors=[];let image=null,owner,now=0;
// 共享资源仓库替身按 authority 与 assetId 定位;此远程消费场景禁止调用仓库收集。
const sharedRecords=new Map(),depotPort={async read(_leader,authority,id){return sharedRecords.get(JSON.stringify([authority,id]));},async collect(){throw Error('remote consumer does not collect');}};
// commit 同步更新共享资源引用,缺少新字节时继续使用已有字节,供跨项目采用验证。
const share=batch=>{for(const write of batch.depot?.writes??[]){const key=JSON.stringify([write.ref.authority,write.ref.asset.id]);sharedRecords.set(key,{ref:write.ref,bytes:write.bytes??sharedRecords.get(key)?.bytes});}};
const storage={assetDepot:depotPort,async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;share(batch);return{durableRootRevision:next.rootRevision};},async readFile(_held,id){return files.get(id);},close(){}};
// 项目存储使用独占 owner;commit 克隆 Root,按批次增删本地文件并写共享 depot,readFile 从项目仓库读。
const storage={assetDepot:depotPort,
// 一个 fixture 项目一次只允许一个持有者;release 使旧 owner 失活并允许后续重开。
async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},
// 恢复镜像克隆后交给引擎,避免复用同一个可变 JSON 对象。
async recover(){return structuredClone(image);},
// 当前 owner 才能提交;文件变化、Root 镜像和 depot 更新在此内存调用内处理,不模拟真实磁盘事务故障。
async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;share(batch);return{durableRootRevision:next.rootRevision};},
// 项目本地文件读取;跨项目共享字节由另一个 assetDepot 端口提供。
async readFile(_held,id){return files.get(id);},close(){}};
// 轮询定时器只登记在 Map,不依赖真实等待。
let nextTimer=0;const timers=new Map();
// 触发当前已到期的定时器,按 due 再按 ID 稳定排序,并返回本轮触发数供 AI 场景有界排空。
const tick=()=>{const ready=[...timers].filter(([,timer])=>timer.due<=now).sort((a,b)=>a[1].due-b[1].due||a[0]-b[0]);let fired=0;for(const[id,timer]of ready)if(timers.delete(id)){fired++;timer.callback();}return fired;};
// 文档、资产和显示读取使用生产适配器;显示 URL 指向同一真实 API,时钟采用可控定时器。
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),assets=createFoundryAssets({workspaceId,project,origin:'https://api.example.com',transport,displayURL:id=>`${basePath}workspaces/${workspaceId}/assets/${encodeURIComponent(id)}?variant=preview`}),clock={now:()=>now,setTimer(callback,delay){const id=++nextTimer;timers.set(id,{callback,due:now+delay});return id;},clearTimer(id){timers.delete(id);}};
// 每次重开从同一 scope、存储及服务端口恢复;extra 可为渐进加载场景替换捕获模型和 loading 状态。
const open=(extra={})=>openProjectEngine({scope,registry,storage,persistence,assets,document:initial.document,versions:initial.versions,clock,createId:randomUUID,onError:error=>errors.push(error),...extra});
// 创建合法文本节点外壳;各场景再替换类型、归属或 data,ID 使用随机 UUID 避免污染既有 fixture 身份。
const node=(id,ownerCanvasId=rootId)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
// 后续 try/finally 持有最新引擎引用,跨场景重开后最终关闭当前实例。
let engine=await open();
try{
// 文档写入真实成功但客户端未知;重开查询原请求,不新增 POST,完整读取再确认远端内容。
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:sourceMember.id,incarnation:1},patch:{text:'committed before lost ACK'}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(request.phase,'waiting');const sent=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await engine.close();now=10_000;engine=await open();await engine.settle();const restored=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert.equal(restored.body,request.body);assert.equal(restored.phase,'resolved');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+request.id)));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,sent);
let live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(sourceMember.id).data.text,'committed before lost ACK');
// 同一事务创建父级锚点、两层分区和连线,作为深复制及解散的真实服务样本。
const anchor=node(randomUUID()),frame={...node(randomUUID()),componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Nested source',text:'',presentation:{contentScale:2}}},childId=randomUUID(),member=node(randomUUID(),childId),nested={...node(randomUUID(),childId),componentId:'frame'},grandchildId=randomUUID(),leaf=node(randomUUID(),grandchildId),edge={id:randomUUID(),incarnation:1,ownerCanvasId:rootId,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:anchor.id},target:{elementId:frame.id},path:null};
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:anchor});tx.dispatch({type:'element.create',element:frame});tx.dispatch({type:'canvas.create',canvas:{id:childId,parent:{canvasId:rootId,entryElementId:frame.id}}});tx.dispatch({type:'element.create',element:member});tx.dispatch({type:'element.create',element:nested});tx.dispatch({type:'canvas.create',canvas:{id:grandchildId,parent:{canvasId:childId,entryElementId:nested.id}}});tx.dispatch({type:'element.create',element:leaf});tx.dispatch({type:'element.create',element:edge});}).status,'applied');await engine.settle();
const creation=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(creation.phase,'resolved','nested partition creation was not confirmed: '+JSON.stringify({phase:creation.phase,outcome:creation.outcome,error:creation.error,response:calls.findLast(call=>call.requestId===creation.id)?.response}));
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.ok(live.document.canvases.has(childId));
// 同时选父 frame 与其成员以检查深复制去重;复制后上级锚点应新增一条指向新分区的连线,redo 用新 incarnation。
const copiedId=await engine.imports.duplicate([{id:frame.id,incarnation:1},{id:member.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const copied=engine.imports.getJobs().get(copiedId);assert.equal(copied.status,'completed');const copiedFrame=copied.stableIds['element:'+frame.id],copiedMember=copied.stableIds['element:'+member.id];assert.ok(copiedFrame&&copiedMember);
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(copiedMember));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===copiedFrame));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(copiedFrame).incarnation,2);
// Dissolve is an owning-tree edit, not recursive deletion. Lose the successful
// real DB response, reopen/query, then restore and repeat the same history unit.
// 解散修改 owning tree,不递归删除入口或后代;真实 DB 成功后丢 ACK,再查询恢复并重做同一历史单元。
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'canvas.dissolve',canvasId:childId,entry:{id:frame.id,incarnation:1}}).status,'applied');await engine.settle();
// 解散已经在数据库完成但本地 waiting;记录请求次数和冻结体,再重开查询,不能发送第二次解散。
const dissolveRequest=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1),dissolveWrites=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;assert.equal(dissolveRequest.phase,'waiting');assert.equal(loseNextAck,false,'dissolve did not receive a successful real service response');
await engine.close();now+=10_000;engine=await open();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).body,dissolveRequest.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,dissolveWrites);
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.canvases.has(childId),false);assert.equal(live.document.elements.get(member.id).ownerCanvasId,rootId);assert.equal(live.document.elements.get(nested.id).ownerCanvasId,rootId);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,rootId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);assert.deepEqual(live.document.elements.get(leaf.id).geometry,leaf.geometry);assert.equal(live.document.elements.get(frame.id).incarnation,1);assert.deepEqual(live.document.elements.get(edge.id).target,{elementId:frame.id});
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(member.id).ownerCanvasId,childId);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.canvases.has(childId),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.equal(live.document.canvases.get(childId).parent.entryElementId,frame.id);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,childId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);
// 逐项导入先接受一个合法元素再遇未知 kind,要求 partial 报告且保留已提交来源 key。
const importId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'native',record:node('native','$target')})+'\n'+JSON.stringify({kind:'unsupported'})+'\n']),rootId);await engine.settle();const imported=engine.imports.getJobs().get(importId);assert.equal(imported.status,'partial');assert.equal(imported.committedSourceKeys.length,1);
// Put a deterministic sorted tail in a different owner. Every original ID is
// lower than these records, so the first real 64-record frame certifies the
// root/new child while the original child is provably still incomplete.
// 尾部 ID 严格大于全部原 ID,且属于另一 owner;真实 64 项首帧可认证根/新子分区,但原有子分区仍缺成员。
// 收集现有元素、canvas 与墓碑 ID,计算严格更大的 70 个尾部 ID,使流排序和缺失归属可预测。
const namespace=[...engine.getSnapshot().document.elements.keys(),...engine.getSnapshot().document.canvases.keys(),...engine.getSnapshot().document.tombstones.keys()];
const maximum=namespace.reduce((max,id)=>{const value=BigInt('0x'+id.replaceAll('-',''));return value>max?value:max;},0n);assert.ok(maximum+70n<(1n<<128n));
const tail=Array.from({length:70},(_,i)=>{const hex=(maximum+BigInt(i+1)).toString(16).padStart(32,'0');return node(`${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`,sourceMember.ownerCanvasId);});
assert.equal(engine.editor.transaction(tx=>{for(const element of tail)tx.dispatch({type:'element.create',element});}).status,'applied');await engine.settle();
// Consume the real negotiated stream through the production decoder, pausing
// after the first nonempty frame. HTTP/DB ACKs may advance before its old tail.
// 消费生产解码器的真实协商流,并暂停在首个非空帧;旧捕获尾部到达前,HTTP/DB ACK 可继续推进。
await engine.close();image=null;initial=await loadFoundryModel(transport,workspaceId,rootId,registry);
// 协商真实 model-stream-v1 并交给生产解码器;preview 提取前缀而不把它冒充完整基线。
const streamed=await transport({method:'GET',path:path+'/model',responseFormat:'model-stream-v1'});assert.ok(streamed.stream);const controller=new AbortController(),preview=createModelPreview(registry);let capture,sourceToken,sourceVersions;
const stream=decodeModelStream(streamed.stream,rootId,registry,controller.signal);
// 读取到第一个非空 receiving 帧即暂停,保留同一迭代器和来源 token/版本供稍后继续消费。
while(true){const next=await stream.next();assert.equal(next.done,false);const chunk=next.value;
const value=preview.update(chunk.value);if(chunk.value.status==='receiving'){capture=value;sourceToken=chunk.snapshotToken;sourceVersions=chunk.versions;if(chunk.value.receivedElements>0)break;}
}
assert.ok(capture.sourceCanvases&&capture.elementOwners);assert.ok(capture.document.elements.size<initial.document.elements.size);assert.equal(capture.document.elements.has(tail.at(-1).id),false);
// v6 loading 描述包含完整来源树、元素 owner 和成员计数;允许已认证完整 owner 内作业,而未载入尾部仍保持缺失。
const loading={version:6,sourceToken,sourceCanvases:capture.sourceCanvases,elementOwners:capture.elementOwners,canvasElementCounts:capture.canvasElementCounts,declaredCanvasIds:capture.sourceCanvases.map(canvas=>canvas.id),receivedElements:capture.receivedElements,totalElements:capture.totalElements};
engine=await open({document:capture.document,versions:sourceVersions,loading});assert.equal(engine.editor.getQueries().membersResult(sourceMember.ownerCanvasId).complete,false);assert.equal(engine.imports.canStart(childId).enabled,true);const partialId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'partial-native',record:node('partial-native','$target')})+'\n']),childId);await engine.settle();assert.equal(engine.imports.getJobs().get(partialId).status,'completed');assert.equal(engine.loading.getState().version,6);
// 旧流尾尚未到达时深复制已具备闭包的分区;不能为了复制强行等待整个项目,也不能把缺失尾部当作已加载。
const partialCopyId=await engine.imports.duplicate([{id:frame.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const partialCopy=engine.imports.getJobs().get(partialCopyId);assert.equal(partialCopy.status,'completed');assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);const partialFrame=partialCopy.stableIds['element:'+frame.id];assert.ok(partialFrame);
// The upload commits on the real server, then its response is deliberately
// lost. Reopening must query that upload before either image document sends.
// 上传真实提交后丢响应;恢复必须先查询原上传,两个图片文档不能绕过该依赖或重复上传。
loseNextAssetAck=true;const imageId=randomUUID();let imageResource;
// 将真实 PNG 暂存为资源并立即创建图片;上传已经成功后丢响应,图片文档须等待同一上传回执协调。
const imageResult=engine.stageFiles([{name:'live.png',bytes:new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR4nGP4z8AAQQxwFgBB0gX7h/C5SAAAAABJRU5ErkJggg==','base64')],{type:'image/png'})}],([ref],tx)=>{imageResource=ref;tx.dispatch({type:'element.create',element:{...node(imageId,childId),componentId:'image',data:{title:'Live scoped image',text:'',assetId:ref.id,src:ref.src}}});});assert.equal(imageResult.status,'applied');assert.equal(engine.getSnapshot().document.elements.has(imageId),true);await engine.settle();const upload=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='asset.upload');assert.ok(upload);assert.equal(loseNextAssetAck,false,'successful upload response was not intercepted');assert.equal(upload.phase,'waiting');const uploadCount=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length;
// 复制尚未确认上传的图片仍引用同一资源,重开查询原上传而非再次传字节。
const imageCopyId=await engine.imports.duplicate([{id:imageId,incarnation:1}],{viewCanvasId:childId});await engine.settle();const imageCopy=engine.imports.getJobs().get(imageCopyId);assert.equal(imageCopy.status,'completed');const copiedImage=imageCopy.stableIds['element:'+imageId];assert.equal(engine.getSnapshot().document.elements.get(copiedImage).data.assetId,imageResource.id);assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);
await engine.close();now+=10_000;engine=await open({document:capture.document,versions:sourceVersions,loading});await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).body,upload.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/assets/operations/'+upload.id)));
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(imageId).data.assetId,imageResource.id);assert.equal(live.document.elements.get(copiedImage).data.assetId,imageResource.id);
// 继续消费原迭代器直到 ready 和 EOF,再用原 token/版本完成 loading;旧尾部合并不能覆盖期间新增内容。
let terminal;for await(const chunk of stream){preview.update(chunk.value);if(chunk.value.status==='ready')terminal=chunk.value;}assert.ok(terminal,'real source never reached terminal and EOF');
assert.equal((await engine.loading.complete(terminal.document,sourceVersions,{sourceToken})).status,'applied');await engine.settle();live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(engine.imports.getJobs().get(partialId).stableIds['element:partial-native']));assert.ok(live.document.elements.has(partialFrame));assert.ok(live.document.elements.has(tail.at(-1).id));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===partialFrame));
// 独立读取资源授权元数据及原字节,和本地资源记录、暂存文件比较,不仅检查图片元素的 assetId。
const authorized=await assets.authority.resolve(imageResource.id,new AbortController().signal),originalBytes=await assets.authority.read(imageResource.id,new AbortController().signal),record=engine.getSnapshot().runtime.resources.get(imageResource.id);
assert.equal(authorized.digest,record.digest);assert.deepEqual(authorized.asset,record.stored);assert.equal(originalBytes.size,record.byteLength);assert.deepEqual(Buffer.from(await originalBytes.arrayBuffer()),Buffer.from(await files.get(imageResource.id).arrayBuffer()));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
// A second canonical root in the same workspace adopts the first root's
// authorized identity. Shared bytes remain local; the real server validates
// the new document reference independently, with no second upload.
// 同 workspace 的第二个 canonical root 采用已有授权资源;共享字节保留,真实服务独立验证新引用,无需第二次上传。
await engine.close();
// 在同一隔离 workspace 内建立第二个 material 并迁移为规范项目,供资源跨项目采用场景使用。
const targetCreated=await transport({method:'POST',path:`workspaces/${workspaceId}/materials`,body:JSON.stringify({kind:'board',title:'Cross-project depot target',content:{}})});assert.equal(targetCreated.status,201);const targetRoot=targetCreated.body.id;
const targetMigration=await loadFoundryModel(transport,workspaceId,targetRoot,registry);assert.equal(targetMigration.status,'migration-required');const targetMigrationId=randomUUID();assert.equal((await transport({method:'POST',path:`workspaces/${workspaceId}/materials/${targetRoot}/canvas/model/migrate`,requestId:targetMigrationId,body:JSON.stringify({protocol_version:2,request_id:targetMigrationId,expected_versions:Object.fromEntries(targetMigration.sourceVersions)})})).status,200);
const targetInitial=await loadFoundryModel(transport,workspaceId,targetRoot,registry);assert.equal(targetInitial.status,'ready');let targetImage=null,targetOwner,adoptionOnly=true;const targetFiles=new Map();
const targetStorage={...storage,async claim(targetScope){assert.equal(targetOwner,undefined);const held={scope:targetScope,fence:1,active:true,async release(){held.active=false;targetOwner=undefined;}};targetOwner=held;return held;},async recover(){return structuredClone(targetImage);},async commit(held,batch){assert.strictEqual(held,targetOwner);if(adoptionOnly)assert.equal((batch.files??[]).length,0,'adoption duplicated project-local bytes');for(const file of batch.files??[]){assert.equal(file.bytes.type.startsWith('image/'),false,'managed image copy duplicated bitmap bytes');targetFiles.set(file.id,file.bytes);}for(const id of batch.deleteFiles??[])targetFiles.delete(id);targetImage=structuredClone(batch.image);share(batch);return{durableRootRevision:targetImage.rootRevision};},async readFile(_held,id){return targetFiles.get(id);}};
// 第二项目有独立 owner、镜像和文件表,但共享 depot;采用阶段不允许复制文件,后续也不允许复制托管图片字节。
const targetStorage={...storage,
// 第二项目有独立占用,不能借用第一个项目的 owner。
async claim(targetScope){assert.equal(targetOwner,undefined);const held={scope:targetScope,fence:1,active:true,async release(){held.active=false;targetOwner=undefined;}};targetOwner=held;return held;},
// 重开读取第二项目自己的镜像,不覆盖第一个项目的持久化记录。
async recover(){return structuredClone(targetImage);},
// 采用阶段禁止任何项目文件写入;AI 扩展后允许非图片文件,但图片仍必须复用托管资源。
async commit(held,batch){assert.strictEqual(held,targetOwner);if(adoptionOnly)assert.equal((batch.files??[]).length,0,'adoption duplicated project-local bytes');for(const file of batch.files??[]){assert.equal(file.bytes.type.startsWith('image/'),false,'managed image copy duplicated bitmap bytes');targetFiles.set(file.id,file.bytes);}for(const id of batch.deleteFiles??[])targetFiles.delete(id);targetImage=structuredClone(batch.image);share(batch);return{durableRootRevision:targetImage.rootRevision};},
// 这里只读第二项目文件表,已有图片原字节必须由共享 depot 解析。
async readFile(_held,id){return targetFiles.get(id);}};
// 第二引擎的文档与 persistence 改绑新 root,资产权威端口及共享仓库仍属于同一 workspace。
const targetOptions={scope:{accountId,workspaceId,projectId:targetRoot},registry,storage:targetStorage,assets,persistence:createFoundryPersistence({registry,workspaceId,rootId:targetRoot,transport}),document:targetInitial.document,versions:targetInitial.versions,clock,createId:randomUUID,onError:error=>errors.push(error)};
// 采用已有授权资源后真实服务验证新项目引用;重开从共享字节显示,undo/redo 只改变元素生命期,不再次上传。
engine=await openProjectEngine(targetOptions);const adoptedId=randomUUID();assert.equal((await engine.adoptResource(imageResource.id,(handle,tx)=>tx.dispatch({type:'element.create',element:{...node(adoptedId,targetRoot),componentId:'image',data:{title:'Adopted existing image',text:'',assetId:handle.id,src:handle.src}}}))).status,'applied');await engine.settle();assert.equal(targetImage.formatVersion,13);assert.equal((await loadFoundryModel(transport,workspaceId,targetRoot,registry)).document.elements.get(adoptedId).data.assetId,imageResource.id);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
await engine.close();engine=await openProjectEngine(targetOptions);const display=await engine.displayResource(imageResource.id);assert.ok(display.url.startsWith('blob:'));assert.deepEqual(Buffer.from(await(await fetch(display.url)).arrayBuffer()),Buffer.from(await originalBytes.arrayBuffer()));display.release();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal((await loadFoundryModel(transport,workspaceId,targetRoot,registry)).document.elements.get(adoptedId).incarnation,2);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
// 仅 FOUNDRY_TEST_AI_WORKER=1 时运行 AI 扩展;随后允许测试作业写入非图片源文件,但仍检查不重复存图片字节。
let aiReport={};if(process.env.FOUNDRY_TEST_AI_WORKER==='1'){
await engine.close();adoptionOnly=false;aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,tick,readDisplay:async url=>{const response=await authenticatedFetch(url);assert.equal(response.status,200);return Buffer.from(await response.arrayBuffer());},advance:()=>{now+=10_000;},loseAdmission:()=>{loseNextAIAdmission=true;},loseAction:()=>{loseNextAIAction=true;}});
}
// 容许的错误只有显式注入的丢响应;其他异步错误不能被一并吞掉。
assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(','));
// 全部断言通过才输出本次集成结果标记;AI 标记只有开关执行该分支时才加入。
console.log(JSON.stringify({status:'ok',...aiReport,migrationCoordinator:true,migrationLostAckRecovered:true,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,dissolveCanvas:true,dissolveLostAckRecovered:true,dissolveUndoRedo:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length}));
}finally{await engine.close();}
+26 -4
View File
@@ -1,33 +1,55 @@
/** Imports compiled ESM directly: no TypeScript stripping or application bundler. */
/**
* 浏览器直接消费 /package 的编译 ESM:不依赖 TypeScript stripping 或应用 bundler。
* 真实测试 module Worker、PNG 解码、SHA-256 及浏览器存储恢复;网络显式阻止,不验证后端写入。
*/
import {createRegistry,builtinComponents,createDocument} from '/package/modules/canvas-core/index.js';
import {openProjectEngine} from '/package/modules/canvas-runtime/index.js';
import {createFoundryPersistence} from '/package/modules/canvas-service/index.js';
import {createBrowserStorage,createBrowserDigests,createBrowserImageDecoder} from '/package/modules/canvas-runtime/browser.js';
// 由浏览器检查入口调用并返回已通过场景的文字;所有断言在实际浏览器能力上执行。
export async function run(){
// 最小断言在页面端抛错,由外层 runner 读取失败;results 只有对应断言完成后才追加。
const assert=(condition,message)=>{if(!condition)throw Error(message);},results=[];
// 每次使用随机数据库名,创建生产 storage、digest 和 image 适配器,避免复用其他测试数据库。
const name=`headless-package-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),digests=createBrowserDigests(),images=createBrowserImageDecoder();
// 独立随机项目和空文档基线;重开时仍使用这组 scope 和同一 storage。
const scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:crypto.randomUUID()},registry=createRegistry(builtinComponents()),document=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
// 记录 Worker URL 和活跃数;保存原生构造器以便清理时恢复全局对象。
const workerUrls=[],NativeWorker=globalThis.Worker;let active=0;
globalThis.Worker=class extends NativeWorker{constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
// 包装真实 Worker 而非模拟消息:构造成功后计数,terminate 只扣减一次,仍调用原生终止方法。
globalThis.Worker=class extends NativeWorker{
// 先完成原生构造,再记录 URL 与活跃计数;保留正常 Worker 消息和错误行为。
constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}
// 同一实例多次终止只扣减一次计数,避免负数掩盖泄漏;每次仍调用原生 terminate。
terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
let engine;
try{
// 用 OffscreenCanvas 生成真实 4×2 PNG,检查编译图片 Worker 的尺寸结果。
const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});
const dimensions=await images.decode(bytes);assert(dimensions.width===4&&dimensions.height===2,'compiled image Worker dimensions');
// 将编译哈希 Worker 结果与浏览器原生 SHA-256 对照,不使用预设摘要跳过实际字节读取。
const digest=await digests.digest(bytes),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join('');assert(digest===expected,'compiled digest Worker SHA256');
// Worker 必须请求发布的 .js 文件,任务结束后活跃数为零;这验证 URL 布局和正常结束时的释放。
assert(workerUrls.some(url=>url.endsWith('/image.worker.js'))&&workerUrls.some(url=>url.endsWith('/digest.worker.js')),'Worker URLs did not resolve emitted JS');assert(active===0,'compiled Workers leaked');
results.push('compiled image and digest module Workers load emitted JS, decode a real PNG, match native SHA256 and terminate');
// 再验证 16 MiB 数据摘要及释放;耗时仅记为本机观察值,没有性能 SLA 断言。
const largeBytes=new Blob([new Uint8Array(16*1024*1024).fill(97)]),largeExpected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await largeBytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),started=performance.now(),largeDigest=await digests.digest(largeBytes),digestMs=performance.now()-started;assert(largeDigest===largeExpected&&active===0,'large compiled digest returned wrong bytes or retained its Worker');results.push('16 MiB compiled digest Worker matched SHA256 in '+digestMs.toFixed(1)+' ms (observation, not an SLA)');
// Keep networking explicitly held: verify the actual browser durability adapter
// independently of an invented backend protocol implementation.
// 显式禁止网络派发,单独验证实际浏览器持久化适配器,避免引入虚构后端协议。
// 借用真实编译端口但声明不支持任何请求,因此编辑只保存在本地;若意外进入网络就直接失败。
const persistence={...createFoundryPersistence({registry,rootId:scope.projectId,workspaceId:scope.workspaceId,transport:async()=>{throw Error('unexpected network');}}),supportsRequest:()=>false};
// 以相同 scope 重新装配引擎,触发真实浏览器存储的 owner 申请和镜像恢复。
const open=()=>openProjectEngine({scope,registry,document,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:()=>crypto.randomUUID()});
engine=await open();
// 创建一次文本元素和一个临时视图,前者及历史应恢复,后者必须随旧会话消失。
const target={id:crypto.randomUUID(),incarnation:1};
assert(engine.editor.dispatch({type:'element.create',element:{...target,ownerCanvasId:scope.projectId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Packed recovery',text:'persisted'},geometry:{x:12,y:24,width:160,height:80,rotation:0}}}).status==='applied','compiled command rejected');
engine.sessions.attach('temporary-view');
// 等待本地保存后关闭/重开,检查修订不回退、文档和一个 undo 单元保留、views 为空。
await engine.settle();const revision=engine.getSnapshot().rootRevision;await engine.close();engine=await open();
assert(engine.getSnapshot().rootRevision>=revision,'compiled IndexedDB recovery revision regressed');assert(engine.getSnapshot().document.elements.get(target.id)?.data.text==='persisted','compiled durable document lost');assert(engine.getSnapshot().history.undo.length===1,'compiled durable history lost');assert(engine.sessions.getSnapshot().views.size===0,'transient sessions persisted');await engine.settle();
results.push('compiled browser storage and engine claim/release Web Locks and recover an edited document/history from IndexedDB without persisting views');
// 仅在全部 Worker 和存储断言通过后返回场景结果。
return results;
// 清理当前引擎和适配器,恢复原生 Worker,并发起删除测试数据库;这里不等待 deleteDatabase 的完成事件。
}finally{await engine?.close();images.close();digests.close();storage.close();globalThis.Worker=NativeWorker;indexedDB.deleteDatabase(name);}
}
+30 -4
View File
@@ -1,3 +1,8 @@
/**
* 发布包消费测试入口。先执行 build-headless 生成 .headless-dist,再离线 npm pack 到临时目录,从解包产物验证 Node、类型和浏览器入口。
* 执行会触发编译/打包及浏览器检查,不是 node:test 的轻量单测;最后清理临时消费者目录,构建目录仍由构建脚本管理。
* 内嵌模板是待生成的独立消费者源码,注释在各模板外说明,避免改变测试输入本身。
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, realpath, rm, writeFile, mkdir, symlink } from 'node:fs/promises';
@@ -5,17 +10,25 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// 以脚本路径定位仓库根,避免调用者 cwd 不同导致打包错误目录。
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
// 临时目录先解析真实路径,后面的包内路径守卫据此判定 import 是否越出解包根。
const temporary=await realpath(await mkdtemp(join(tmpdir(),'endoleap-headless-package-')));
// 同步运行子步骤,默认在仓库根且捕获输出;options 可指定消费者 cwd 和独立 npm cache,失败交给外层统一报告。
const run=(program,args,options={})=>execFileSync(program,args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe'],...options});
try {
// 先编译真正发布产物;后面的测试都消费该产物,不能用源文件 TypeScript stripping 冒充包可用。
run(process.execPath,['scripts/build-headless.mjs']);
// npm pack 禁用生命周期脚本、使用离线模式并写入临时目录;这验证已有构建产物的包装,不触发额外安装。
const packed=JSON.parse(run('npm',['pack','--offline','--ignore-scripts','--json','--pack-destination',temporary],{cwd:join(root,'.headless-dist'),env:{...process.env,npm_config_cache:join(temporary,'npm-cache')}}));
assert.equal(packed.length,1);
// 解包唯一 tarball,后续 packageRoot 代表实际发布文件布局。
run('tar',['-xzf',join(temporary,packed[0].filename),'-C',temporary]);
const packageRoot=join(temporary,'package');
// 读取打包后的 package.json 与 README,检查私有 ESM 包、无直接运行时依赖,以及可选框架 peer 元数据。
const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8'));
const readme=await readFile(join(packageRoot,'README.md'),'utf8');
// 逐个检查 README 的相对 .md 链接可在包内读取且非空;外部 URL 不在此处做网络可达性验证。
for(const match of readme.matchAll(/\]\(([^)]+\.md)\)/g)){
if(/^[a-z]+:/i.test(match[1]))continue;
assert.ok((await readFile(join(packageRoot,match[1]),'utf8')).length>0,`packed README link is empty: ${match[1]}`);
@@ -23,7 +36,8 @@ try {
assert.equal(metadata.private,true);assert.equal(metadata.type,'module');
assert.equal(Object.keys(metadata.dependencies??{}).length,0);
assert.deepEqual(Object.keys(metadata.peerDependencies??{}).sort(),['@xyflow/react','react','react-dom']);assert.ok(Object.values(metadata.peerDependenciesMeta).every(value=>value.optional===true));
// Resolve every import through the packed artifact, never checkout node_modules.
// portable 消费者的非内建 import 必须来自解包产物,不能偷偷借用仓库 node_modules。
// 生成 ESM resolve 钩子:node: 内建模块放行,其他解析结果必须是 packageRoot 内的 file URL。
await writeFile(join(temporary,'guard-loader.mjs'),`
import {fileURLToPath} from 'node:url';
import {sep} from 'node:path';
@@ -35,14 +49,17 @@ try {
return result;
}
`);
// 生成 Node 启动守卫:注册路径钩子,并将 window/document/navigator/IndexedDB/Worker 读取设为抛错 getter。
await writeFile(join(temporary,'guard.mjs'),`
import {register} from 'node:module';
register('./guard-loader.mjs',import.meta.url);
for(const name of ['window','document','navigator','indexedDB','Worker'])Object.defineProperty(globalThis,name,{configurable:true,get(){throw Error('Browser global: '+name);}});
`);
// 在独立进程运行打包 consumer.mjs;portable、重开和元素数由消费者真实执行结果断言,不从源码文本推断。
const report=JSON.parse(run(process.execPath,['--import',join(temporary,'guard.mjs'),join(packageRoot,'consumer.mjs')],{cwd:temporary}));
assert.equal(report.status,'ok');assert.equal(report.portableBindings,true);assert.equal(report.reopened,true);assert.equal(report.elements,2);
// No DOM library: browser adapter declarations must not leak into portable ports.
// 不加载 DOM 类型库,验证 portable 端口的公开声明没有泄漏浏览器专属类型。
// 生成不带 DOM 的 TS 消费者:检查 loading v2-v6、导入可用性、部分查询、场景投影/命中和 StoragePort 装配的公开类型。
await writeFile(join(packageRoot,'consumer-types.mts'),`
import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core';
import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime';
@@ -71,13 +88,15 @@ try {
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
}
`);
// 仅使用 ES2022 与 Node 类型进行 noEmit 检查,防止 portable 端口意外依赖浏览器声明。
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022','--types','node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'consumer-types.mts')],{cwd:temporary});
// A separate consumer opts into framework peers. The portable guard above
// remains strict and has already proved /bindings does not resolve any peers.
// 框架 peers 由第二个消费者显式接入;上面的路径守卫保持严格,/bindings 不能依赖这些 peers。
// 另一个显式启用框架的消费者才连接 React/ReactFlow peers,前面的严格 portable 检查已独立完成。
await mkdir(join(packageRoot,'node_modules/@xyflow'),{recursive:true});
for(const peer of ['react','react-dom','@xyflow/react'])await symlink(await realpath(join(root,'node_modules',peer)),join(packageRoot,'node_modules',peer),'dir');
await mkdir(join(packageRoot,'node_modules/@types'),{recursive:true});
for(const peer of ['react','react-dom'])await symlink(await realpath(join(root,'node_modules/@types',peer)),join(packageRoot,'node_modules/@types',peer),'dir');
// 生成 SSR 消费者:HookView 读取公开 hooks,View 取得 renderer 命令 context;实际渲染并派发一次命令检查绑定。
await writeFile(join(packageRoot,'react-consumer.mjs'),`
import assert from 'node:assert/strict';
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';
@@ -93,7 +112,9 @@ try {
const html=renderToStaticMarkup(React.createElement(CanvasRendererCommandsContext.Provider,{value:commands},React.createElement(renderers.nodeTypes.fixture)));
assert.equal(html,'<span>peer renderer</span>');assert.strictEqual(seen,commands);seen.dispatch({type:'element.data',target:{id:'fixture',incarnation:1},patch:{title:'command'}});assert.equal(dispatched,1);console.log('PASS explicit React peer renderer/context consumer');
`);
// 运行实际解包后的 React 入口,显示该独立消费者输出。
const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react);
// 生成框架 TS 消费者:Hooks 检查选择器、actions 和资源/操作视图,Card/Link 分别验证公开节点及边投影类型。
await writeFile(join(packageRoot,'react-consumer-types.mts'),`
import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react';
import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow';
@@ -105,15 +126,20 @@ try {
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){return createElement('span',null,data?.__headlessRecord?.id);}
export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]);
`);
// 框架消费者单独启用 DOM 和 React 类型;这不能替代前面不带 DOM 的 portable 检查。
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022,DOM,DOM.Iterable','--types','react,react-dom,node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'react-consumer-types.mts')],{cwd:temporary});
// 继续执行浏览器包消费场景,直接加载编译 ESM 与 Worker、IndexedDB/Web Locks,独立验证浏览器适配器。
const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']);
process.stdout.write(browser);
// 只有所有消费和类型检查完成后才输出整体报告;packedFiles 取自实际 npm pack 结果。
console.log(JSON.stringify({relativeDocumentation:true,compiledBrowserAdapters:true,portableBindings:true,explicitReactPeers:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
// 保留子进程 stdout/stderr 便于定位失败,再抛出原错误使入口失败。
} catch(error){
if(error.stdout)process.stderr.write(error.stdout);
if(error.stderr)process.stderr.write(error.stderr);
throw error;
} finally {
// 成功或失败都删除临时消费者目录;不在这里删除仓库的 .headless-dist。
await rm(temporary,{recursive:true,force:true});
}
+29 -2
View File
@@ -1,4 +1,8 @@
/** Compile and exercise the actual Next route in a disposable frontend project. */
/**
* 在临时前端项目中编译实际 Next API route,连接本地 HTTP 上游替身,检查真实代理路径。
* 执行本脚本会启动 Next dev 并产生构建文件;不是纯静态检查,也不是远程 Foundry 数据库集成。
* 覆盖冻结字节、认证头、资产元数据/上传、AI 协议与事件游标、流式首块,以及路径 allowlist。
*/
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, copyFile, writeFile, symlink, rm } from 'node:fs/promises';
@@ -7,11 +11,17 @@ import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises';
// 临时目录只复制代理必需文件,received 保存上游实际收到的请求,避免只验证前端 fetch 参数。
const source=resolve(import.meta.dirname,'../..'),directory=await mkdtemp(join(tmpdir(),'endoleap-proxy-check-')),received=[];
// 测试专用账户和幂等键都是固定假值,不读取真实用户登录凭据。
const account='10000000-0000-4000-8000-000000000004',key='90000000-0000-4000-8000-000000000002';
// 响应特意保留空白和 extension,要求代理把终态回执原字节透传而非重建 JSON。
const responseBytes=' { "protocol_version":2, "request_id":"'+key+'", "status":"not-applied", "terminal":true, "error":"fixture_rejection", "extension":{"retained":true} }\n';
// 流尾由测试释放,首块可先到客户端;用来判断代理是否缓冲了整条上游响应。
let streamRelease;const streamGate=new Promise(resolve=>streamRelease=resolve);
// 仅用于代理传输的最小 NDJSON 片段,不是完整模型解码样本。
const streamPrefix='{"streamVersion":1,"kind":"header"}\n',streamTail='{"streamVersion":1,"kind":"complete"}\n';
// 上游先收集 body 和 headers;模型流先写前缀再等待,其余路径返回可辨识 JSON 或 409 回执。
const upstream=createServer(async(request,response)=>{
const chunks=[];for await(const chunk of request)chunks.push(chunk);
received.push({path:request.url,body:Buffer.concat(chunks),headers:request.headers});
@@ -19,18 +29,25 @@ const upstream=createServer(async(request,response)=>{
response.writeHead(request.url.endsWith('/model/operations')?409:200,{'Content-Type':'application/json'});
response.end(request.url.endsWith('/model/operations')?responseBytes:'{"id":"fixture"}');
});
// 系统分配上游监听端口;另一个短暂监听器只为 Next 选端口,释放后再启动 Next,未构成持续端口锁。
await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve));
const reservation=createServer();await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve));const port=reservation.address().port;await new Promise(resolve=>reservation.close(resolve));
// 保存子进程、有限长度启动日志和 spawn 错误,供 readiness 失败时定位原因。
let next,logs='',launchError;
try{
// 复制实际 route 及它依赖的响应/会话模块;临时页面仅满足 Next 项目结构,不替换被测 route。
for(const filename of ['package.json','tsconfig.json','src/app/api/foundry/[...path]/route.ts','src/lib/canvas-response.ts','src/lib/session-cookie.ts']){
const target=join(directory,filename);await mkdir(resolve(target,'..'),{recursive:true});await copyFile(join(source,filename),target);
}
// 复用当前依赖树而不重新安装,临时项目的源码和编译目录与主项目分开。
await symlink(join(source,'node_modules'),join(directory,'node_modules'));
await writeFile(join(directory,'src/app/layout.tsx'),'export default function Layout({children}:{children:React.ReactNode}){return <html><body>{children}</body></html>}');
await writeFile(join(directory,'src/app/page.tsx'),'export default function Page(){return <main>Proxy verification fixture</main>}');
// 显式运行 webpack 开发服务器并指向本地上游,detached 便于 finally 按进程组清理。
next=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),'dev','--webpack','--hostname','127.0.0.1','--port',String(port)],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'fixture'},stdio:['ignore','pipe','pipe']});
// 只保留最后 12000 字符日志;启动失败和退出必须被 readiness 循环识别。
next.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.on('error',error=>{launchError=error});
// 最多 90 次探测,单次请求超时 1.5 秒、失败间隔 250 毫秒;只有实际代理返回 200 才开始断言。
let ready=false;
for(let attempt=0;attempt<90;attempt++){
if(launchError)throw launchError;if(next.exitCode!==null)throw new Error('Next exited during startup:\n'+logs);
@@ -38,22 +55,28 @@ try{
await pause(250);
}
if(!ready)throw new Error('Next did not become ready:\n'+logs);
// 规范文档操作路径使用固定 workspace/material,后续从它推导资产及 AI 路由。
const path='workspaces/10000000-0000-4000-8000-000000000003/materials/10000000-0000-4000-8000-000000000001/canvas/model/operations';
// More than the generic 1 MiB proxy cap, below the canonical 5 MiB cap.
// 1.1 MB 请求超过通用 1 MiB 上限但低于规范文档专用 5 MiB 上限,专门覆盖该路由的体积策略。
const body=' {\n "protocol_version":2,"request_id":"'+key+'","fixture":"'+ 'x'.repeat(1100000)+'"\n} ';
// 发送超过通用限制但低于文档专用限制的原始 body,核对上游状态码及终态回执字节不变。
const response=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}`,{method:'POST',body,headers:{'Content-Type':'application/json','Idempotency-Key':key,'X-Expected-Account':account,Cookie:'foundry_session=fixture-session',Origin:`http://127.0.0.1:${port}`},signal:AbortSignal.timeout(20000)});
assert.equal(response.status,409);assert.equal(await response.text(),responseBytes);
// 从上游记录检查 body、幂等键、预期账户,以及 session cookie 转成 Bearer 的实际结果。
const request=received.find(item=>item.path.endsWith('/model/operations'));
assert.ok(request);assert.equal(request.body.toString(),body);assert.equal(request.headers['idempotency-key'],key);assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers.authorization,'Bearer fixture-session');
// 资产回执和元数据是 JSON 路径;元数据还检查身份传递与 no-store,避免误走图片 body 验证。
const receiptPath=`workspaces/10000000-0000-4000-8000-000000000003/assets/operations/${key}`;
const assetReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath}`);assert.equal(assetReceipt.status,200);assert.deepEqual(await assetReceipt.json(),{id:'fixture'});
const metadata=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath.replace('/operations/','/metadata/')}`,{headers:{'X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});assert.equal(metadata.status,200);assert.deepEqual(await metadata.json(),{id:'fixture'});assert.match(metadata.headers.get('cache-control'),/no-store/);const metadataRequest=received.find(item=>item.path.includes('/assets/metadata/'));assert.equal(metadataRequest.headers['x-expected-account'],account);assert.equal(metadataRequest.headers.authorization,'Bearer fixture-session');
console.log('PASS asset metadata JSON uses the actual authenticated Next proxy without image-body validation or caching');
// FormData 自带 boundary,检查代理保留 multipart 字节及 X-Resource-Id,而不自行重新编码文件。
const multipart=new FormData();multipart.append('image',new Blob(['immutable file'],{type:'image/png'}),'fixture.png');
const assetPath=receiptPath.split('/operations/')[0],resourceId='10000000-0000-4000-8000-000000000006';
const uploaded=await fetch(`http://127.0.0.1:${port}/api/foundry/${assetPath}`,{method:'POST',body:multipart,headers:{'X-Resource-Id':resourceId,'Idempotency-Key':key,'X-Expected-Account':account}});await uploaded.text();assert.equal(uploaded.status,200);
const upload=received.find(item=>item.path.endsWith('/assets'));assert.equal(upload.headers['x-resource-id'],resourceId);assert.equal(upload.headers['idempotency-key'],key);assert.match(upload.body.toString(),/immutable file/);assert.match(upload.headers['content-type'],/^multipart\/form-data; boundary=/);
console.log('PASS asset receipt JSON and multipart resource identity cross the actual Next proxy');
// AI 受理同样保留冻结 JSON 空白、幂等键和 If-Match,并协商 canvas v2;查询回执与事件游标单独检查。
const aiPath=path.replace('/model/operations','/ai/runs');
const frozenAI=' {\n "protocol_version":2,"request_id":"'+key+'","prompt":"保留请求字节" } ';
const aiResponse=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}`,{method:'POST',body:frozenAI,headers:{'Content-Type':'application/json','Idempotency-Key':key,'If-Match':'"7"','X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});
@@ -65,15 +88,19 @@ try{
const aiEvents=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}/${key}?after_seq=41&limit=100`);await aiEvents.text();assert.equal(aiEvents.status,200);
assert.ok(received.some(item=>item.path.endsWith('/ai/runs/'+key+'?limit=100&after_seq=41')));
console.log('PASS frozen AI bytes, identity, version header, admission receipt and event cursor cross the actual Next proxy');
// 上游尾部仍挂起时读取第一块,随后释放并核对余下字节,验证端到端增量转发。
const streamed=await fetch(`http://127.0.0.1:${port}/api/foundry/${path.replace('/operations','')}`,{headers:{Accept:'application/vnd.foundry.canvas-model+ndjson;version=1'}});
assert.equal(streamed.headers.get('content-type'),'application/vnd.foundry.canvas-model+ndjson;version=1');
const reader=streamed.body.getReader(),first=await reader.read();assert.equal(new TextDecoder().decode(first.value),streamPrefix);streamRelease();let rest='';while(true){const next=await reader.read();if(next.done)break;rest+=new TextDecoder().decode(next.value);}assert.equal(rest,streamTail);reader.releaseLock();
console.log('PASS negotiated canonical NDJSON and first chunk cross the actual Next proxy before the gated tail');
// 多余路径段应在代理 allowlist 被拒绝,不能发到上游再依赖上游返回错误。
const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before);
console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB');
console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream');
}finally{
// 清理时先放开挂起流,再终止 Next 进程组;超时未退出则强制结束。
streamRelease();
if(next?.pid){const exited=new Promise(resolve=>{if(next.exitCode!==null)resolve();else next.once('exit',resolve)});try{process.kill(-next.pid,'SIGTERM')}catch{}await Promise.race([exited,pause(3000)]);if(next.exitCode===null){try{process.kill(-next.pid,'SIGKILL')}catch{}}}
// 断开上游残留连接并关闭监听,最后删除本次临时项目目录。
upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));await rm(directory,{recursive:true,force:true,maxRetries:5});
}
+29
View File
@@ -1,3 +1,7 @@
/**
* Foundry 文档适配器的编译、回执和传输契约测试。真实适配器连接可控 transport/fetch 替身,不访问远程 HTTP 或数据库。
* 区分协议版本、文档格式、服务版本向量与元素 incarnation;HTTP 成功、未知结果和明确零写入必须有各自证据。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
@@ -5,22 +9,34 @@ import { createDocument, createRegistry, builtinComponents, applyChanges } from
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);
@@ -32,6 +48,7 @@ test('initial model read is explicit about migration/coverage and owns opaque re
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);
@@ -43,6 +60,7 @@ test('compiler freezes protocol 2 conditional changes and transports the exact o
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],
@@ -55,6 +73,7 @@ test('only explicit identity-matched terminal proof is zero-write; bare 4xx stay
}
});
// 局部 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);
@@ -65,6 +84,7 @@ test('partial canonical receipts replace participants and retain unrelated futur
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');
@@ -72,6 +92,7 @@ test('missing logical coverage and altered frozen metadata do not masquerade as
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'}}}]};
@@ -79,6 +100,7 @@ test('new canvases carry an explicit absent version without confusing format/pro
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'}})}});
@@ -90,6 +112,7 @@ test('browser transport fences identity changes and never rebuilds a frozen body
});
// 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'}})}});
@@ -99,6 +122,7 @@ test('shared service transport binds the immutable AI admission version header',
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()}};});
@@ -112,6 +136,7 @@ test('recovery reader requires a complete canonical model for its bound project
]){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);
@@ -126,6 +151,7 @@ test('historical applied proof validates frozen identity and coverage, retains n
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'}]};
@@ -141,6 +167,7 @@ test('ordered batch freezes every logical identity, conditional change and parti
});
// 只在有账本压缩依据时减少 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();
@@ -153,9 +180,11 @@ test('Foundry compiles the proven projection without changing logical intents or
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/);
});