@@ -333,6 +333,225 @@ describe('runCreateBatch --run --wait: mixed outcomes', () => {
333333 } ) ;
334334} ) ;
335335
336+ // ---------------------------------------------------------------------------
337+ // No --wait: exit-code contract (issue #161)
338+ //
339+ // Without --wait, every trigger response is non-terminal ('queued') by design.
340+ // A fully successful dispatch must exit 0 — success means every trigger was
341+ // dispatched without error, mirroring single `test run` (no --wait). A trigger
342+ // error must still exit non-zero. Existing no-wait specs only exercised error
343+ // scenarios and never asserted the success exit code, which is how the
344+ // "always exits 1 even when every trigger succeeds" bug slipped through.
345+ // ---------------------------------------------------------------------------
346+
347+ describe ( 'runCreateBatch --run (no --wait): exit-code contract' , ( ) => {
348+ let logSpy : ReturnType < typeof vi . spyOn > ;
349+ let errorSpy : ReturnType < typeof vi . spyOn > ;
350+
351+ beforeEach ( ( ) => {
352+ logSpy = vi . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } ) ;
353+ errorSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
354+ } ) ;
355+
356+ afterEach ( ( ) => {
357+ logSpy . mockRestore ( ) ;
358+ errorSpy . mockRestore ( ) ;
359+ } ) ;
360+
361+ it ( 'all triggers succeed (queued) → resolves without error, exit 0; results all queued, no error (json)' , async ( ) => {
362+ const { credentialsPath } = makeCreds ( ) ;
363+ const testIds = [ 'test_q1' , 'test_q2' , 'test_q3' ] ;
364+ const plansFile = writePlansJsonl ( [ FE_SPEC , FE_SPEC , FE_SPEC ] ) ;
365+
366+ let pollCount = 0 ;
367+ const fetchImpl = makeFetch ( url => {
368+ if ( url . includes ( '/tests/batch' ) ) {
369+ return { body : makeBatchCreateResponse ( testIds ) } ;
370+ }
371+ const triggerMatch = / \/ t e s t s \/ ( t e s t _ [ a - z 0 - 9 ] + ) \/ r u n s $ / . exec ( url ) ;
372+ if ( triggerMatch ?. [ 1 ] ) {
373+ const testId = triggerMatch [ 1 ] ;
374+ return { body : makeTriggerResponse ( testId , `run_${ testId } ` ) } ;
375+ }
376+ // No --wait must NOT poll — count any GET /runs as a violation.
377+ if ( / \/ r u n s \/ r u n _ t e s t _ [ a - z 0 - 9 ] + / . exec ( url ) ) {
378+ pollCount ++ ;
379+ }
380+ return {
381+ status : 404 ,
382+ body : {
383+ error : { code : 'NOT_FOUND' , message : 'not found' , nextAction : '' , requestId : 'r1' } ,
384+ } ,
385+ } ;
386+ } ) ;
387+
388+ const stdout : string [ ] = [ ] ;
389+ const stderrLines : string [ ] = [ ] ;
390+
391+ // Must NOT throw — a fully successful no-wait dispatch exits 0.
392+ // (If runBatchRun threw a CLIError, this await would reject and fail the test.)
393+ await runCreateBatch (
394+ {
395+ profile : 'default' ,
396+ output : 'json' ,
397+ debug : false ,
398+ dryRun : false ,
399+ plans : plansFile ,
400+ run : true ,
401+ wait : false ,
402+ timeoutSeconds : 60 ,
403+ } ,
404+ {
405+ credentialsPath,
406+ fetchImpl,
407+ stdout : line => stdout . push ( line ) ,
408+ stderr : line => stderrLines . push ( line ) ,
409+ sleep : instantSleep ,
410+ } ,
411+ ) ;
412+
413+ expect ( pollCount ) . toBe ( 0 ) ; // no polling without --wait
414+
415+ const printed = JSON . parse ( stdout . join ( '' ) ) as {
416+ results : Array < { testId : string ; status : string ; error ?: unknown } > ;
417+ } ;
418+ expect ( printed . results ) . toHaveLength ( 3 ) ;
419+ expect ( printed . results . every ( r => r . status === 'queued' ) ) . toBe ( true ) ;
420+ expect ( printed . results . every ( r => r . error === undefined ) ) . toBe ( true ) ;
421+ } ) ;
422+
423+ it ( 'all triggers succeed (queued) → text summary reports "3/3 triggered", exit 0 (no "0/N passed")' , async ( ) => {
424+ const { credentialsPath } = makeCreds ( ) ;
425+ const testIds = [ 'test_t1' , 'test_t2' , 'test_t3' ] ;
426+ const plansFile = writePlansJsonl ( [ FE_SPEC , FE_SPEC , FE_SPEC ] ) ;
427+
428+ const fetchImpl = makeFetch ( url => {
429+ if ( url . includes ( '/tests/batch' ) ) {
430+ return { body : makeBatchCreateResponse ( testIds ) } ;
431+ }
432+ const triggerMatch = / \/ t e s t s \/ ( t e s t _ [ a - z 0 - 9 ] + ) \/ r u n s $ / . exec ( url ) ;
433+ if ( triggerMatch ?. [ 1 ] ) {
434+ const testId = triggerMatch [ 1 ] ;
435+ return { body : makeTriggerResponse ( testId , `run_${ testId } ` ) } ;
436+ }
437+ return {
438+ status : 404 ,
439+ body : {
440+ error : { code : 'NOT_FOUND' , message : 'not found' , nextAction : '' , requestId : 'r1' } ,
441+ } ,
442+ } ;
443+ } ) ;
444+
445+ const stdout : string [ ] = [ ] ;
446+ const stderrLines : string [ ] = [ ] ;
447+
448+ // Must NOT throw — a fully successful no-wait dispatch exits 0.
449+ await runCreateBatch (
450+ {
451+ profile : 'default' ,
452+ output : 'text' ,
453+ debug : false ,
454+ dryRun : false ,
455+ plans : plansFile ,
456+ run : true ,
457+ wait : false ,
458+ timeoutSeconds : 60 ,
459+ } ,
460+ {
461+ credentialsPath,
462+ fetchImpl,
463+ stdout : line => stdout . push ( line ) ,
464+ stderr : line => stderrLines . push ( line ) ,
465+ sleep : instantSleep ,
466+ } ,
467+ ) ;
468+
469+ const summary = stderrLines . find ( l => l . startsWith ( 'batch-run summary:' ) ) ;
470+ expect ( summary ) . toBeDefined ( ) ;
471+ expect ( summary ) . toContain ( '3/3 triggered' ) ;
472+ // The pre-fix bug printed a pass/fail summary ("0/3 passed") for a fully
473+ // successful no-wait dispatch — assert that misleading wording is gone.
474+ expect ( summary ) . not . toContain ( 'passed' ) ;
475+ expect ( stderrLines . some ( l => l . includes ( 'did not pass' ) ) ) . toBe ( false ) ;
476+ } ) ;
477+
478+ it ( 'partial trigger failure (2 queued, 1 errors) → still exits non-zero; all 3 results in envelope' , async ( ) => {
479+ const { credentialsPath } = makeCreds ( ) ;
480+ const testIds = [ 'test_p1' , 'test_p2' , 'test_p3' ] ;
481+ const plansFile = writePlansJsonl ( [ FE_SPEC , FE_SPEC , FE_SPEC ] ) ;
482+
483+ // test_p3's trigger returns 404 NOT_FOUND — a non-retryable error (exit 4)
484+ // that surfaces immediately as an error result. The other two dispatch fine.
485+ const fetchImpl = makeFetch ( url => {
486+ if ( url . includes ( '/tests/batch' ) ) {
487+ return { body : makeBatchCreateResponse ( testIds ) } ;
488+ }
489+ const triggerMatch = / \/ t e s t s \/ ( t e s t _ [ a - z 0 - 9 ] + ) \/ r u n s $ / . exec ( url ) ;
490+ if ( triggerMatch ?. [ 1 ] ) {
491+ const testId = triggerMatch [ 1 ] ;
492+ if ( testId === 'test_p3' ) {
493+ return {
494+ status : 404 ,
495+ body : {
496+ error : {
497+ code : 'NOT_FOUND' ,
498+ message : 'test not found' ,
499+ nextAction : '' ,
500+ requestId : 'req_p3' ,
501+ } ,
502+ } ,
503+ } ;
504+ }
505+ return { body : makeTriggerResponse ( testId , `run_${ testId } ` ) } ;
506+ }
507+ return {
508+ status : 404 ,
509+ body : {
510+ error : { code : 'NOT_FOUND' , message : 'not found' , nextAction : '' , requestId : 'r1' } ,
511+ } ,
512+ } ;
513+ } ) ;
514+
515+ const stdout : string [ ] = [ ] ;
516+ const stderrLines : string [ ] = [ ] ;
517+
518+ const err = await runCreateBatch (
519+ {
520+ profile : 'default' ,
521+ output : 'json' ,
522+ debug : false ,
523+ dryRun : false ,
524+ plans : plansFile ,
525+ run : true ,
526+ wait : false ,
527+ timeoutSeconds : 60 ,
528+ } ,
529+ {
530+ credentialsPath,
531+ fetchImpl,
532+ stdout : line => stdout . push ( line ) ,
533+ stderr : line => stderrLines . push ( line ) ,
534+ sleep : instantSleep ,
535+ } ,
536+ ) . catch ( e => e ) ;
537+
538+ // A dispatch with any trigger error must exit non-zero.
539+ expect ( err ) . toBeInstanceOf ( CLIError ) ;
540+ expect ( ( err as CLIError ) . exitCode ) . not . toBe ( 0 ) ;
541+
542+ const printed = JSON . parse ( stdout . join ( '' ) ) as {
543+ results : Array < { testId : string ; status : string ; error ?: { code : string } } > ;
544+ } ;
545+ expect ( printed . results ) . toHaveLength ( 3 ) ;
546+ const queued = printed . results . filter ( r => r . status === 'queued' ) ;
547+ const errored = printed . results . filter ( r => r . error !== undefined ) ;
548+ expect ( queued ) . toHaveLength ( 2 ) ;
549+ expect ( errored ) . toHaveLength ( 1 ) ;
550+ expect ( errored [ 0 ] ?. testId ) . toBe ( 'test_p3' ) ;
551+ expect ( errored [ 0 ] ?. error ?. code ) . toBe ( 'NOT_FOUND' ) ;
552+ } ) ;
553+ } ) ;
554+
336555// ---------------------------------------------------------------------------
337556// --max-concurrency: verify only N in-flight at any time
338557// ---------------------------------------------------------------------------
0 commit comments