Files
Endoleap/tests/headless/foundry.integration.mjs
T

177 lines
32 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 CI runner 的隔离 PostgreSQL/HTTP 集成环境调用。文档、迁移和资产写入使用真实服务;本地恢复存储及丢响应是测试替身。
* 执行前必须注入隔离服务身份与测试数据;本文件不启动数据库,也不在没有 fixture 时降级为本地服务。
* 依次覆盖迁移、未知回执恢复、分区复制/解散、增量加载期间导入与上传,以及跨项目资源采用;AI 场景由环境开关追加。
*/
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
import {createFoundryTransport,createFoundryPersistence,loadFoundryModel} from '../../src/lib/canvas-service/foundry.ts';
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
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]]))};
// 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});}};
// 项目存储使用独占 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);
// 解散修改 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);
// 尾部 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();
// 消费生产解码器的真实协商流,并暂停在首个非空帧;旧捕获尾部到达前,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);
// 上传真实提交后丢响应;恢复必须先查询原上传,两个图片文档不能绕过该依赖或重复上传。
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);
// 同 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();
// 第二项目有独立 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();}