@@ -37,8 +37,10 @@ import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
3737const logger = createLogger ( 'WorkflowStopAfterE2E' )
3838const execFileAsync = promisify ( execFile )
3939const MAX_RESPONSE_BYTES = 2 * 1024 * 1024
40- /** The first execute request cold-compiles the route under `next dev`. */
41- const REQUEST_TIMEOUT_MS = 300_000
40+ /** The first execute request cold-compiles the route's module graph under `next dev`. */
41+ const ROUTE_COMPILE_TIMEOUT_MS = 300_000
42+ /** Every later request hits the compiled route; the slowest fixture run waits about `SLOW_MS`. */
43+ const REQUEST_TIMEOUT_MS = 60_000
4244const SLOW_SECONDS = 4
4345const SLOW_MS = SLOW_SECONDS * 1000
4446const startedAt = new Date ( ) . toISOString ( )
@@ -67,7 +69,8 @@ const personalKey = `sk-sim-fixture-${generateId()}`
6769const cliPath = fileURLToPath ( new URL ( '../../../packages/sim-cli/src/index.ts' , import . meta. url ) )
6870const checks : { name : string ; status : 'passed' | 'failed' ; durationMs : number ; error ?: string } [ ] =
6971 [ ]
70- const requests : { method : string ; path : string ; status : number ; durationMs : number } [ ] = [ ]
72+ /** `status` is null when the request ended without a complete response. */
73+ const requests : { method : string ; path : string ; status : number | null ; durationMs : number } [ ] = [ ]
7174let directory : string | undefined
7275
7376interface PipelineFixture {
@@ -225,37 +228,48 @@ async function seed() {
225228 } )
226229}
227230
231+ function isTimeout ( error : unknown ) : boolean {
232+ return error instanceof DOMException && error . name === 'TimeoutError'
233+ }
234+
228235async function execute (
229236 workflowId : string ,
230237 body : V2ExecuteWorkflowBody ,
231- expectedStatus = 200
238+ { expectedStatus = 200 , timeoutMs = REQUEST_TIMEOUT_MS } = { }
232239) : Promise < Record < string , unknown > > {
233240 const url = new URL ( `/api/v2/workflows/${ workflowId } /execute` , baseUrl )
234241 const started = performance . now ( )
235- // boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP
236- const response = await fetch ( url , {
237- method : 'POST' ,
238- redirect : 'error' ,
239- signal : AbortSignal . timeout ( REQUEST_TIMEOUT_MS ) ,
240- headers : {
241- accept : 'application/json' ,
242- 'content-type' : 'application/json' ,
243- 'x-api-key' : personalKey ,
244- 'x-forwarded-for' : '127.0.0.1' ,
245- } ,
246- body : JSON . stringify ( body ) ,
247- } )
248- requests . push ( {
249- method : 'POST' ,
250- path : url . pathname ,
251- status : response . status ,
252- durationMs : Math . round ( performance . now ( ) - started ) ,
253- } )
254- const text = await readResponseTextWithLimit ( response , {
255- maxBytes : MAX_RESPONSE_BYTES ,
256- label : 'Stop-after E2E response' ,
257- } )
258- assert . equal ( response . status , expectedStatus , `${ url . pathname } : ${ truncate ( text , 500 ) } ` )
242+ const elapsed = ( ) => Math . round ( performance . now ( ) - started )
243+ let status : number | null = null
244+ let text : string
245+ try {
246+ // boundary-raw-fetch: protocol E2E exercises a separately running local app over real HTTP
247+ const response = await fetch ( url , {
248+ method : 'POST' ,
249+ redirect : 'error' ,
250+ signal : AbortSignal . timeout ( timeoutMs ) ,
251+ headers : {
252+ accept : 'application/json' ,
253+ 'content-type' : 'application/json' ,
254+ 'x-api-key' : personalKey ,
255+ 'x-forwarded-for' : '127.0.0.1' ,
256+ } ,
257+ body : JSON . stringify ( body ) ,
258+ } )
259+ status = response . status
260+ text = await readResponseTextWithLimit ( response , {
261+ maxBytes : MAX_RESPONSE_BYTES ,
262+ label : 'Stop-after E2E response' ,
263+ } )
264+ } catch ( error ) {
265+ const reason = isTimeout ( error )
266+ ? `no complete response within ${ timeoutMs / 1000 } s`
267+ : getErrorMessage ( error )
268+ throw new Error ( `POST ${ url . pathname } failed after ${ elapsed ( ) } ms: ${ reason } ` )
269+ } finally {
270+ requests . push ( { method : 'POST' , path : url . pathname , status, durationMs : elapsed ( ) } )
271+ }
272+ assert . equal ( status , expectedStatus , `${ url . pathname } : ${ truncate ( text , 500 ) } ` )
259273 return record ( JSON . parse ( text ) )
260274}
261275
@@ -268,56 +282,90 @@ async function run(
268282 return data
269283}
270284
271- async function expectBadRequest ( workflowId : string , body : V2ExecuteWorkflowBody , code : string ) {
272- const status = code === 'NOT_FOUND' ? 404 : 400
273- const error = record ( ( await execute ( workflowId , body , status ) ) . error )
285+ async function expectBadRequest (
286+ workflowId : string ,
287+ body : V2ExecuteWorkflowBody ,
288+ code : string ,
289+ timeoutMs = REQUEST_TIMEOUT_MS
290+ ) {
291+ const expectedStatus = code === 'NOT_FOUND' ? 404 : 400
292+ const error = record ( ( await execute ( workflowId , body , { expectedStatus, timeoutMs } ) ) . error )
274293 assert . equal ( error . code , code )
275294}
276295
277- async function runCli ( args : string [ ] ) : Promise < V2ExecuteWorkflowData > {
296+ /** Runs the CLI; a run it must fail exits non-zero and still prints the run on stdout. */
297+ async function execCli (
298+ args : string [ ]
299+ ) : Promise < { exitCode : number ; stdout : string ; stderr : string } > {
278300 assert ( directory , 'CLI fixture directory must exist' )
279- const { stdout } = await execFileAsync (
280- 'bun' ,
281- [
282- '--no-env-file' ,
283- cliPath ,
284- '--endpoint' ,
285- baseUrl . origin ,
286- '--workspace' ,
287- workspaceId ,
288- '--output' ,
289- 'json' ,
290- 'workflows' ,
291- 'run' ,
292- ...args ,
293- ] ,
294- {
295- cwd : directory ,
296- env : { ...process . env , SIM_CONFIG_DIR : directory , SIM_API_KEY : personalKey , NO_COLOR : '1' } ,
297- timeout : REQUEST_TIMEOUT_MS ,
298- maxBuffer : MAX_RESPONSE_BYTES ,
299- }
301+ try {
302+ const { stdout, stderr } = await execFileAsync (
303+ 'bun' ,
304+ [
305+ '--no-env-file' ,
306+ cliPath ,
307+ '--endpoint' ,
308+ baseUrl . origin ,
309+ '--workspace' ,
310+ workspaceId ,
311+ '--output' ,
312+ 'json' ,
313+ 'workflows' ,
314+ 'run' ,
315+ ...args ,
316+ ] ,
317+ {
318+ cwd : directory ,
319+ env : { ...process . env , SIM_CONFIG_DIR : directory , SIM_API_KEY : personalKey , NO_COLOR : '1' } ,
320+ timeout : REQUEST_TIMEOUT_MS ,
321+ maxBuffer : MAX_RESPONSE_BYTES ,
322+ }
323+ )
324+ return { exitCode : 0 , stdout, stderr }
325+ } catch ( error ) {
326+ assert ( isRecordLike ( error ) , getErrorMessage ( error ) )
327+ assert ( ! error . killed , `sim workflows run did not exit within ${ REQUEST_TIMEOUT_MS / 1000 } s` )
328+ assert (
329+ typeof error . code === 'number' &&
330+ typeof error . stdout === 'string' &&
331+ typeof error . stderr === 'string' ,
332+ getErrorMessage ( error )
333+ )
334+ return { exitCode : error . code , stdout : error . stdout , stderr : error . stderr }
335+ }
336+ }
337+
338+ async function runCli ( args : string [ ] ) : Promise < V2ExecuteWorkflowData > {
339+ const { exitCode, stdout, stderr } = await execCli ( args )
340+ assert . equal (
341+ exitCode ,
342+ 0 ,
343+ `sim workflows run exited ${ exitCode } : ${ truncate ( stderr || stdout , 500 ) } `
300344 )
301345 return v2ExecuteWorkflowDataSchema . parse ( JSON . parse ( stdout ) )
302346}
303347
304348/** A CLI run the command itself must fail: exits non-zero and prints the failed run. */
305349async function runCliExpectingFailure ( args : string [ ] ) : Promise < V2ExecuteWorkflowData > {
306- try {
307- await runCli ( args )
308- } catch ( error ) {
309- assert ( isRecordLike ( error ) && typeof error . stdout === 'string' , getErrorMessage ( error ) )
310- assert . notEqual ( error . code , 0 , 'a failed run must exit non-zero' )
311- return v2ExecuteWorkflowDataSchema . parse ( JSON . parse ( error . stdout ) )
312- }
313- assert . fail ( 'the CLI exited 0 for a run that must fail' )
350+ const { exitCode, stdout } = await execCli ( args )
351+ assert . notEqual ( exitCode , 0 , 'the CLI exited 0 for a run that must fail' )
352+ return v2ExecuteWorkflowDataSchema . parse ( JSON . parse ( stdout ) )
314353}
315354
316355const selectAll = [ 'Slow.status' , 'Check.status' , 'After.status' ]
317356
318357try {
319358 await check ( 'seed disposable workspace, personal key and fixtures' , seed )
320359
360+ await check ( 'the execute route compiles and refuses a run before it starts' , ( ) =>
361+ expectBadRequest (
362+ pipeline . workflowId ,
363+ { run : { source : 'manual' , stopAfterBlockId : '' } } ,
364+ 'BAD_REQUEST' ,
365+ ROUTE_COMPILE_TIMEOUT_MS
366+ )
367+ )
368+
321369 let sourceRunId = ''
322370 await check ( 'a full manual run executes every block and persists its state' , async ( ) => {
323371 const full = await run ( pipeline . workflowId , {
0 commit comments