/** * 由 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.sizecanvas.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();}