test(canvas): drive real HTTP run polling with controlled timers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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}));
|
||||
|
||||
Reference in New Issue
Block a user