@@ -36,6 +36,7 @@ import {
3636 runPlanPut ,
3737 runResult ,
3838 runSteps ,
39+ runTestWaitMany ,
3940 runUpdate ,
4041} from './test.js' ;
4142
@@ -3042,6 +3043,160 @@ describe('runLint', () => {
30423043 } ) ;
30433044} ) ;
30443045
3046+ describe ( 'runTestWaitMany' , ( ) => {
3047+ const terminalRun = ( runId : string , status : string ) => ( {
3048+ runId,
3049+ testId : `test_of_${ runId } ` ,
3050+ projectId : 'project_alice' ,
3051+ userId : 'u1' ,
3052+ status,
3053+ source : 'cli' ,
3054+ createdAt : '2026-06-01T10:00:00.000Z' ,
3055+ startedAt : '2026-06-01T10:00:01.000Z' ,
3056+ finishedAt : '2026-06-01T10:00:30.000Z' ,
3057+ codeVersion : 'v1' ,
3058+ targetUrl : 'https://example.com' ,
3059+ createdFrom : null ,
3060+ failedStepIndex : null ,
3061+ failureKind : null ,
3062+ error : null ,
3063+ videoUrl : null ,
3064+ stepSummary : { total : 1 , completed : 1 , passedCount : 1 , failedCount : 0 } ,
3065+ } ) ;
3066+
3067+ it ( 'polls every run, keeps input order, and exits 1 when one finished non-passed' , async ( ) => {
3068+ const { credentialsPath } = makeCreds ( ) ;
3069+ const fetchImpl = makeFetch ( url => ( {
3070+ body : url . includes ( 'run_bad' )
3071+ ? terminalRun ( 'run_bad' , 'failed' )
3072+ : terminalRun ( 'run_ok' , 'passed' ) ,
3073+ } ) ) ;
3074+ const out : string [ ] = [ ] ;
3075+ const rejection = await runTestWaitMany (
3076+ {
3077+ profile : 'default' ,
3078+ output : 'json' ,
3079+ debug : false ,
3080+ runIds : [ 'run_ok' , 'run_bad' ] ,
3081+ timeoutSeconds : 30 ,
3082+ maxConcurrency : 2 ,
3083+ } ,
3084+ { credentialsPath, fetchImpl, stdout : line => out . push ( line ) } ,
3085+ ) . catch ( ( error : unknown ) => error ) ;
3086+ expect ( rejection ) . toMatchObject ( { exitCode : 1 } ) ;
3087+ const payload = JSON . parse ( out . join ( '' ) ) as {
3088+ results : Array < { runId : string ; status : string } > ;
3089+ summary : { passed : number ; failed : number } ;
3090+ } ;
3091+ expect ( payload . results . map ( row => row . runId ) ) . toEqual ( [ 'run_ok' , 'run_bad' ] ) ;
3092+ expect ( payload . summary ) . toMatchObject ( { passed : 1 , failed : 1 } ) ;
3093+ } ) ;
3094+
3095+ it ( 'a member whose poll errors is captured as error:<CODE> and the others survive (exit 7)' , async ( ) => {
3096+ const { credentialsPath } = makeCreds ( ) ;
3097+ const fetchImpl = makeFetch ( url => {
3098+ if ( url . includes ( 'run_gone' ) ) {
3099+ return {
3100+ status : 404 ,
3101+ body : {
3102+ error : {
3103+ code : 'NOT_FOUND' ,
3104+ message : 'no such run' ,
3105+ nextAction : 'check the id' ,
3106+ requestId : 'req_x' ,
3107+ details : { } ,
3108+ } ,
3109+ } ,
3110+ } ;
3111+ }
3112+ return { body : terminalRun ( 'run_ok' , 'passed' ) } ;
3113+ } ) ;
3114+ const out : string [ ] = [ ] ;
3115+ const errs : string [ ] = [ ] ;
3116+ const rejection = await runTestWaitMany (
3117+ {
3118+ profile : 'default' ,
3119+ output : 'json' ,
3120+ debug : false ,
3121+ runIds : [ 'run_ok' , 'run_gone' ] ,
3122+ timeoutSeconds : 30 ,
3123+ maxConcurrency : 2 ,
3124+ } ,
3125+ {
3126+ credentialsPath,
3127+ fetchImpl,
3128+ stdout : line => out . push ( line ) ,
3129+ stderr : line => errs . push ( line ) ,
3130+ } ,
3131+ ) . catch ( ( error : unknown ) => error ) ;
3132+ expect ( rejection ) . toMatchObject ( { exitCode : 7 } ) ;
3133+ const payload = JSON . parse ( out . join ( '' ) ) as {
3134+ results : Array < { runId : string ; status : string } > ;
3135+ } ;
3136+ // The failing member did not abort the pool: the passed verdict survived.
3137+ expect ( payload . results [ 0 ] ) . toMatchObject ( { runId : 'run_ok' , status : 'passed' } ) ;
3138+ expect ( payload . results [ 1 ] ! . status ) . toBe ( 'error:NOT_FOUND' ) ;
3139+ // The re-attach hint names the errored member (resumable) but NOT the
3140+ // already-terminal passed one.
3141+ const hint = errs . find ( line => line . includes ( 'Re-attach with:' ) ) ;
3142+ expect ( hint ) . toContain ( 'run_gone' ) ;
3143+ expect ( hint ) . not . toContain ( 'run_ok' ) ;
3144+ } ) ;
3145+
3146+ it ( 'members dequeued after the shared deadline are not granted extra poll time' , async ( ) => {
3147+ const { credentialsPath } = makeCreds ( ) ;
3148+ let fetches = 0 ;
3149+ const fetchImpl = makeFetch ( ( ) => {
3150+ fetches += 1 ;
3151+ return { body : terminalRun ( 'run_any' , 'passed' ) } ;
3152+ } ) ;
3153+ // timeoutSeconds 0: the shared deadline is already in the past when the
3154+ // pool starts, so every member must resolve to timeout WITHOUT polling
3155+ // (previously each dequeued member was granted a fresh 1s minimum).
3156+ const rejection = await runTestWaitMany (
3157+ {
3158+ profile : 'default' ,
3159+ output : 'json' ,
3160+ debug : false ,
3161+ runIds : [ 'run_a' , 'run_b' , 'run_c' ] ,
3162+ timeoutSeconds : 0 ,
3163+ maxConcurrency : 1 ,
3164+ } ,
3165+ { credentialsPath, fetchImpl, stdout : ( ) => undefined , stderr : ( ) => undefined } ,
3166+ ) . catch ( ( error : unknown ) => error ) ;
3167+ expect ( rejection ) . toMatchObject ( { exitCode : 7 } ) ;
3168+ expect ( fetches ) . toBe ( 0 ) ;
3169+ } ) ;
3170+
3171+ it ( 'an auth error escalates the exit code to 3' , async ( ) => {
3172+ const { credentialsPath } = makeCreds ( ) ;
3173+ const fetchImpl = makeFetch ( ( ) => ( {
3174+ status : 401 ,
3175+ body : {
3176+ error : {
3177+ code : 'AUTH_INVALID' ,
3178+ message : 'bad key' ,
3179+ nextAction : 'run setup' ,
3180+ requestId : 'req_y' ,
3181+ details : { } ,
3182+ } ,
3183+ } ,
3184+ } ) ) ;
3185+ const rejection = await runTestWaitMany (
3186+ {
3187+ profile : 'default' ,
3188+ output : 'json' ,
3189+ debug : false ,
3190+ runIds : [ 'run_a' , 'run_b' ] ,
3191+ timeoutSeconds : 30 ,
3192+ maxConcurrency : 2 ,
3193+ } ,
3194+ { credentialsPath, fetchImpl, stdout : ( ) => undefined } ,
3195+ ) . catch ( ( error : unknown ) => error ) ;
3196+ expect ( rejection ) . toMatchObject ( { exitCode : 3 } ) ;
3197+ } ) ;
3198+ } ) ;
3199+
30453200describe ( 'runResult' , ( ) => {
30463201 it ( 'JSON mode prints the §6.5 LatestResult shape verbatim' , async ( ) => {
30473202 const { credentialsPath } = makeCreds ( ) ;
0 commit comments