From 465596fb7b175e837613fec394777c4615303ffa Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 15:11:41 +0800 Subject: [PATCH] Exercise runtime AI actions through the authenticated Foundry integration client --- tests/headless/foundry-ai.integration.mjs | 70 +++++++++++++++++++++++ tests/headless/foundry.integration.mjs | 11 +++- 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/headless/foundry-ai.integration.mjs diff --git a/tests/headless/foundry-ai.integration.mjs b/tests/headless/foundry-ai.integration.mjs new file mode 100644 index 0000000..6ed7324 --- /dev/null +++ b/tests/headless/foundry-ai.integration.mjs @@ -0,0 +1,70 @@ +/** Remote authenticated HTTP/DB integration. Only the model output is a fixture. */ +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}){ + 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();}; + 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}; + assert.equal(engine.editor.dispatch({type:'element.create',element:{id:target.id,incarnation:1,ownerCanvasId:rootId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Real AI adapter',text:'before generation',extension:{keep:true}},geometry:{x:0,y:0,width:100,height:80,rotation:0}}}).status,'applied');await engine.settle(); + const input={target,boardId:rootId,modelId:'fixture-text',prompt:'Fixture generation',parameters:{},propagate:false}; + const begin=async value=>{const result=engine.startRun(value);assert.equal(result.status,'applied');await engine.settle();return result;}; + const byOperation=id=>[...engine.getSnapshot().runtime.runs.values()].find(run=>run.operationId===id); + const initialStarts=posts('/ai/runs').length,initialUploads=posts('/assets').length; + + // A committed admission whose response was lost must be queried after reopen. + loseAdmission();const first=await begin(input),admission=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.start'&&request.operationIds.includes(first.operationIds[0])); + assert.equal(admission.phase,'waiting');const admissionCount=posts('/ai/runs').length; + await reopen();assert.equal(posts('/ai/runs').length,admissionCount);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(admission.id).body,admission.body); + assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/ai/operations/'+admission.id))); + const firstRun=byOperation(first.operationIds[0]);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'); + 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}); + + // Queued cancellation is durable and does not execute the generation transport. + const second=await begin({...input,prompt:'Fixture cancellation'}),secondRun=byOperation(second.operationIds[0]),beforeCancel=(await control('stats')).executions; + loseAction();assert.equal(engine.cancelRun(secondRun.id).status,'applied');await engine.settle(); + const cancellation=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.cancel'&&request.runId===secondRun.id);assert.equal(cancellation.phase,'waiting');const cancellationCount=posts('/cancel').length; + await reopen();assert.equal(posts('/cancel').length,cancellationCount);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(cancellation.id).body,cancellation.body);assert.equal(engine.getSnapshot().runtime.runs.get(secondRun.id).status,'cancelled'); + assert.equal((await control('stats')).executions,beforeCancel); + + // Edit while the provider is held, then retain and explicitly apply its output. + const third=await begin({...input,prompt:'Fixture conflict'}),thirdRun=byOperation(third.operationIds[0]);await control('arm'); + const executing=control('execute').then(value=>({value}),error=>({error})); + try{ + await control('wait'); + assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'user edit while executing'}}).status,'applied');await engine.settle(); + }finally{await control('release');} + const completed=await executing;if(completed.error)throw completed.error;await poll(); + const conflict=engine.getSnapshot().runtime.runs.get(thirdRun.id);assert.equal(conflict.status,'conflict');assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'user edit while executing'); + const outputId=conflict.outputEvents[0]?.output.output_id;assert.equal(typeof outputId,'string'); + loseAction();assert.equal(engine.applyRunOutput(conflict.id,target,outputId).status,'applied');await engine.settle(); + const application=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.apply'&&request.runId===conflict.id);assert.equal(application.phase,'waiting');const applyCount=posts('/apply').length; + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();await reopen(); + assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'user edit while executing');assert.equal(posts('/apply').length,applyCount);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(application.id).body,application.body); + assert.ok(engine.getSnapshot().runtime.runs.get(conflict.id).appliedOutputIds.includes(outputId)); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output'); + assert.equal(posts('/apply').length,applyCount); + + // Managed image results are readable, and copying shares bytes without a run. + const image=engine.getSnapshot().document.elements.get(imageId),oldAsset=image.data.assetId; + const fourth=await begin({target:{id:image.id,incarnation:image.incarnation},boardId:rootId,modelId:'fixture-image',prompt:'Fixture image',parameters:{},propagate:false}); + const fourthRun=byOperation(fourth.operationIds[0]);await control('execute');await poll(); + assert.equal(engine.getSnapshot().runtime.runs.get(fourthRun.id).status,'succeeded');const generated=engine.getSnapshot().document.elements.get(imageId);assert.notEqual(generated.data.assetId,oldAsset); + const metadata=await assets.authority.resolve(generated.data.assetId,signal),display=await engine.displayResource(generated.data.assetId); + try{const bytes=Buffer.from(await(await fetch(display.url)).arrayBuffer());assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]);assert.equal(createHash('sha256').update(bytes).digest('hex'),metadata.digest);assert.equal(metadata.asset.width,3);assert.equal(metadata.asset.height,2);}finally{display.release();} + const runCount=engine.getSnapshot().runtime.runs.size,copyId=await engine.imports.duplicate([{id:generated.id,incarnation:generated.incarnation}],{viewCanvasId:rootId});await engine.settle(); + const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');assert.equal(engine.getSnapshot().document.elements.get(copy.stableIds['element:'+generated.id]).data.assetId,generated.data.assetId); + assert.equal(engine.getSnapshot().runtime.runs.size,runCount);assert.equal(posts('/ai/runs').length,initialStarts+4);assert.equal(posts('/assets').length,initialUploads);assert.equal((await control('stats')).executions,3); + return {runtimeAI:true,aiLostAdmission:true,aiCancelReconciled:true,aiConflictApplyUndo:true,aiManagedImage:true,aiCopyNoRun:true,aiExecutionCount:3}; + }finally{await engine.close();} +} diff --git a/tests/headless/foundry.integration.mjs b/tests/headless/foundry.integration.mjs index 4736605..5a2a3eb 100644 --- a/tests/headless/foundry.integration.mjs +++ b/tests/headless/foundry.integration.mjs @@ -8,13 +8,15 @@ 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 {verifyFoundryAI} from './foundry-ai.integration.mjs'; import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts'; 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'); const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[]; -let loseNextAck=false,loseNextAssetAck=false; +let loseNextAck=false,loseNextAssetAck=false,loseNextAIAdmission=false,loseNextAIAction=false; const rawTransport=createFoundryTransport({basePath,accountId,isCurrentIdentity:()=>true,fetch:async(resource,init)=>{const headers=new Headers(init.headers);headers.set('Authorization',`Bearer ${token}`);return fetch(resource,{...init,headers});}}); -const transport=async input=>{calls.push({method:input.method,path:input.path,requestId:input.requestId,body:input.body});const result=await rawTransport(input);if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;}; +const transport=async input=>{calls.push({method:input.method,path:input.path,requestId:input.requestId,body:input.body});const result=await rawTransport(input);if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;}; const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`; const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required'); const migrationId=randomUUID(),migrationBody=JSON.stringify({protocol_version:2,request_id:migrationId,expected_versions:Object.fromEntries(migration.sourceVersions)}); @@ -81,6 +83,9 @@ try{ 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)}; 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;}}); + } assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(',')); - console.log(JSON.stringify({status:'ok',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})); + 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})); }finally{await engine.close();}