test(canvas): drive real HTTP run polling with controlled timers

This commit is contained in:
2026-11-11 15:50:08 +08:00
parent 96fc37c319
commit c07f8fbd82
3 changed files with 24 additions and 5 deletions
+16
View File
@@ -1171,3 +1171,19 @@ A final ready plan has already crossed its required preparation durability
barrier. Its encoded facts may therefore already exist, and the ready source
can reuse them without an artificial yield. Tests distinguish first-time
preparation slicing from ready-source byte/mapping preservation and reopening.
### AI wire negotiation
The AI service adapter explicitly requests `responseFormat: 'canvas-v2'` for
admission, cancellation, output application, receipt queries, model catalogs
and run polling. `createFoundryTransport` maps it to
`Accept: application/vnd.foundry.canvas+json;version=2`. A custom `ServiceTransport`
must preserve that negotiation as well as the frozen body, idempotency key,
conditional version and account scope. The Next application proxy also declares
v2, but an independent SDK consumer cannot rely on that proxy being present.
`runs.test.mjs` exercises all three writes and their receipt queries through the
shared HTTP transport. `foundry-ai.integration.mjs` additionally requires a real
successful 202 before testing lost admission recovery; a rejected admission is
not evidence for response-loss recovery. Its remote worker fixture replaces
only generation output, not authenticated HTTP, PostgreSQL or service receipts.
+4 -3
View File
@@ -2,13 +2,13 @@
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
export async function verifyFoundryAI({open,transport,rootId,imageId,assets,calls,advance,loseAdmission,loseAction}){
export async function verifyFoundryAI({open,transport,rootId,imageId,assets,calls,tick,advance,loseAdmission,loseAction}){
const path=`workspaces/${process.env.FOUNDRY_TEST_WORKSPACE}/materials/${rootId}/canvas/ai`;
const control=async action=>{const response=await transport({method:'POST',path:path+'/fixture/'+action,signal:AbortSignal.timeout(15_000)});assert.equal(response.status,200,action+': '+JSON.stringify(response.body));return response.body;};
const posts=suffix=>calls.filter(call=>call.method==='POST'&&call.path.endsWith(suffix));
let engine=await open();const signal=new AbortController().signal;
const reopen=async()=>{await engine.close();advance();engine=await open();await engine.settle();};
const poll=async()=>{await engine.retry();await engine.settle();};
const poll=async()=>{await engine.retry();for(let turns=0;turns<100;turns++){if(!tick())return;await engine.settle();}assert.fail('AI timer drain exceeded its bound');};
try{
const catalog=await engine.models(rootId,signal);assert.ok(catalog.models.some(model=>model.id==='fixture-text'));assert.ok(catalog.models.some(model=>model.id==='fixture-image'));
const target={id:randomUUID(),incarnation:1};
@@ -28,7 +28,8 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
const firstRun=byOperation(first.operationIds[0]);assert.ok(firstRun,'resolved admission must publish a RunRecord');assert.equal(firstRun.status,'queued');
assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:77}}).status,'applied');await engine.settle();
await control('execute');await poll();
assert.equal(engine.getSnapshot().runtime.runs.get(firstRun.id).status,'succeeded');
const generatedText=engine.getSnapshot().runtime.runs.get(firstRun.id);
assert.equal(generatedText.status,'succeeded',JSON.stringify({status:generatedText.status,error:generatedText.error,blocked:generatedText.blocked,cursor:generatedText.cursor}));
assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,77);
assert.deepEqual(engine.getSnapshot().document.elements.get(target.id).data.extension,{keep:true});
+4 -2
View File
@@ -28,7 +28,9 @@ const scope={accountId,workspaceId,projectId:rootId},files=new Map(),errors=[];l
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');}};
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(){}};
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),assets=createFoundryAssets({workspaceId,project,origin:'https://api.example.com',transport}),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};
let nextTimer=0;const timers=new Map();
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;};
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),assets=createFoundryAssets({workspaceId,project,origin:'https://api.example.com',transport}),clock={now:()=>now,setTimer(callback,delay){const id=++nextTimer;timers.set(id,{callback,due:now+delay});return id;},clearTimer(id){timers.delete(id);}};
const open=(extra={})=>openProjectEngine({scope,registry,storage,persistence,assets,document:initial.document,versions:initial.versions,clock,createId:randomUUID,onError:error=>errors.push(error),...extra});
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}});
let engine=await open();
@@ -84,7 +86,7 @@ try{
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);
let aiReport={};if(process.env.FOUNDRY_TEST_AI_WORKER==='1'){
await engine.close();aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,advance:()=>{now+=10_000;},loseAdmission:()=>{loseNextAIAdmission=true;},loseAction:()=>{loseNextAIAction=true;}});
await engine.close();aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,tick,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(','));
console.log(JSON.stringify({status:'ok',...aiReport,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length}));