From c07f8fbd8268d6e71f163ba664bca37af9fb0bae Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 15:50:08 +0800 Subject: [PATCH] test(canvas): drive real HTTP run polling with controlled timers --- headless/README.md | 16 ++++++++++++++++ tests/headless/foundry-ai.integration.mjs | 7 ++++--- tests/headless/foundry.integration.mjs | 6 ++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/headless/README.md b/headless/README.md index b8c5c24..b014de5 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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. diff --git a/tests/headless/foundry-ai.integration.mjs b/tests/headless/foundry-ai.integration.mjs index d0df87b..db02fc3 100644 --- a/tests/headless/foundry-ai.integration.mjs +++ b/tests/headless/foundry-ai.integration.mjs @@ -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}); diff --git a/tests/headless/foundry.integration.mjs b/tests/headless/foundry.integration.mjs index aa0515b..3e8bc71 100644 --- a/tests/headless/foundry.integration.mjs +++ b/tests/headless/foundry.integration.mjs @@ -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}));