@@ -5185,6 +5185,188 @@ export async function runTestRun(
51855185 return finalRun ;
51865186}
51875187
5188+ /** One row of the `test wait <run-id...>` multi-run payload. */
5189+ export interface CliMultiWaitResult {
5190+ runId : string ;
5191+ /** Terminal run status, or 'timeout', or 'error:<CODE>' when the poll failed. */
5192+ status : string ;
5193+ /** Test the run belongs to, when the poll observed it. */
5194+ testId ?: string ;
5195+ }
5196+
5197+ export interface RunTestWaitManyOptions extends CommonOptions {
5198+ runIds : string [ ] ;
5199+ timeoutSeconds : number ;
5200+ maxConcurrency : number ;
5201+ }
5202+
5203+ /**
5204+ * `test wait <run-id...>` with two or more ids: attach to N already-dispatched
5205+ * runs in ONE invocation. This closes the loop the CLI itself opens: every
5206+ * batch/closure timeout prints one `testsprite test wait <runId>` hint PER
5207+ * member, which previously meant N sequential blocking invocations. The runs
5208+ * are polled concurrently under a bounded pool with ONE shared deadline
5209+ * (`--timeout` bounds the whole invocation, not each member), each member's
5210+ * poll is total (a transient error on one run never discards the others), and
5211+ * the exit code is the worst status across members: auth errors escalate to
5212+ * exit 3, any timeout or poll error exits 7, any non-passed terminal exits 1.
5213+ * Distinct from a run journal (issue #80): no persistence, just N known ids.
5214+ */
5215+ export async function runTestWaitMany (
5216+ opts : RunTestWaitManyOptions ,
5217+ deps : TestDeps = { } ,
5218+ ) : Promise < { results : CliMultiWaitResult [ ] ; summary : Record < string , number > } > {
5219+ const out = makeOutput ( opts . output , deps ) ;
5220+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
5221+
5222+ if ( opts . dryRun ) {
5223+ emitDryRunBanner ( stderrFn ) ;
5224+ const results : CliMultiWaitResult [ ] = opts . runIds . map ( runId => ( {
5225+ runId,
5226+ status : 'passed' ,
5227+ } ) ) ;
5228+ const payload = {
5229+ results,
5230+ summary : { total : results . length , passed : results . length , failed : 0 , timedOut : 0 , errors : 0 } ,
5231+ } ;
5232+ out . print ( payload , ( ) => results . map ( r => `${ r . runId } ${ r . status } ` ) . join ( '\n' ) ) ;
5233+ return payload ;
5234+ }
5235+
5236+ const client = makeClient (
5237+ { ...opts , requestTimeoutMs : resolveWaitRequestTimeoutMs ( { ...opts , wait : true } ) } ,
5238+ deps ,
5239+ ) ;
5240+ const ticker = createTicker ( stderrFn , opts . output === 'json' ? false : undefined ) ;
5241+
5242+ // One shared deadline across every member (the whole point of the shared
5243+ // pool: `--timeout 600` means the invocation ends within ~600s, not
5244+ // 600s x ceil(N/concurrency)).
5245+ const deadlineMs = Date . now ( ) + opts . timeoutSeconds * 1000 ;
5246+
5247+ type WaitOutcome =
5248+ | { kind : 'result' ; run : RunResponse }
5249+ | { kind : 'timeout' }
5250+ | { kind : 'error' ; code : string ; exitCode : number } ;
5251+
5252+ const pollOne = async ( runId : string ) : Promise < WaitOutcome > => {
5253+ const resolveAlternate = makeBackendWaitFallback ( {
5254+ client,
5255+ resolveTestId : run => run . testId ,
5256+ resolveNotBefore : run => run . createdAt ,
5257+ onResolved : ( ) => undefined ,
5258+ } ) ;
5259+ try {
5260+ const run = await pollRunUntilTerminal ( client , runId , {
5261+ timeoutSeconds : Math . max ( 1 , Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ) ,
5262+ sleep : deps . sleep ,
5263+ onTransition : opts . verbose ? ( msg : string ) => stderrFn ( `[verbose] ${ msg } ` ) : undefined ,
5264+ onTick : ( run , elapsedMs ) => {
5265+ const elapsed = Math . round ( elapsedMs / 1000 ) ;
5266+ ticker . update ( `Run ${ run . runId } — ${ run . status } (elapsed=${ elapsed } s)` ) ;
5267+ } ,
5268+ resolveAlternate,
5269+ } ) ;
5270+ return { kind : 'result' , run } ;
5271+ } catch ( err ) {
5272+ if ( err instanceof TimeoutError ) return { kind : 'timeout' } ;
5273+ if ( err instanceof RequestTimeoutError ) throw err ;
5274+ if ( err instanceof ApiError ) return { kind : 'error' , code : err . code , exitCode : err . exitCode } ;
5275+ return { kind : 'error' , code : 'TRANSPORT' , exitCode : 10 } ;
5276+ }
5277+ } ;
5278+
5279+ const outcomes = new Map < string , WaitOutcome > ( ) ;
5280+ let inFlight = 0 ;
5281+ let nextIdx = 0 ;
5282+ try {
5283+ await new Promise < void > ( ( resolve , reject ) => {
5284+ const startNext = ( ) : void => {
5285+ while ( inFlight < opts . maxConcurrency && nextIdx < opts . runIds . length ) {
5286+ const runId = opts . runIds [ nextIdx ++ ] ! ;
5287+ inFlight ++ ;
5288+ pollOne ( runId )
5289+ . then ( outcome => {
5290+ outcomes . set ( runId , outcome ) ;
5291+ inFlight -- ;
5292+ startNext ( ) ;
5293+ if ( inFlight === 0 && nextIdx >= opts . runIds . length ) resolve ( ) ;
5294+ } )
5295+ // pollOne is total except for RequestTimeoutError (handled below).
5296+ . catch ( reject ) ;
5297+ }
5298+ } ;
5299+ startNext ( ) ;
5300+ if ( opts . runIds . length === 0 ) resolve ( ) ;
5301+ } ) ;
5302+ } catch ( fanOutErr ) {
5303+ if ( fanOutErr instanceof RequestTimeoutError ) {
5304+ // Same contract as the batch pollers: leave stdout parseable before
5305+ // exiting 7 — every dispatched id is re-attachable in one command.
5306+ ticker . finalize ( 'Multi-run wait — request timed out' ) ;
5307+ const partial = {
5308+ results : opts . runIds . map ( runId => ( { runId, status : 'running' } ) ) ,
5309+ summary : { total : opts . runIds . length } ,
5310+ } ;
5311+ out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } running` ) . join ( '\n' ) ) ;
5312+ stderrFn ( `Re-attach with: testsprite test wait ${ opts . runIds . join ( ' ' ) } ` ) ;
5313+ }
5314+ throw fanOutErr ;
5315+ }
5316+ ticker . finalize ( ) ;
5317+
5318+ const results : CliMultiWaitResult [ ] = opts . runIds . map ( runId => {
5319+ const outcome = outcomes . get ( runId ) ;
5320+ if ( outcome === undefined || outcome . kind === 'timeout' ) return { runId, status : 'timeout' } ;
5321+ if ( outcome . kind === 'error' ) return { runId, status : `error:${ outcome . code } ` } ;
5322+ return { runId, status : outcome . run . status , testId : outcome . run . testId } ;
5323+ } ) ;
5324+ const passed = results . filter ( r => r . status === 'passed' ) . length ;
5325+ const timedOut = results . filter ( r => r . status === 'timeout' ) . length ;
5326+ const errors = results . filter ( r => r . status . startsWith ( 'error:' ) ) . length ;
5327+ const failed = results . length - passed - timedOut - errors ;
5328+ const payload = {
5329+ results,
5330+ summary : { total : results . length , passed, failed, timedOut, errors } ,
5331+ } ;
5332+ out . print ( payload , ( ) =>
5333+ [
5334+ ...results . map ( r => `${ r . runId } ${ r . status } ` ) ,
5335+ '' ,
5336+ `${ passed } /${ results . length } passed, ${ failed } failed/blocked, ${ timedOut } timed out, ${ errors } poll errors` ,
5337+ ] . join ( '\n' ) ,
5338+ ) ;
5339+
5340+ const timedOutIds = results . filter ( r => r . status === 'timeout' ) . map ( r => r . runId ) ;
5341+ if ( timedOutIds . length > 0 ) {
5342+ stderrFn ( `Re-attach with: testsprite test wait ${ timedOutIds . join ( ' ' ) } ` ) ;
5343+ }
5344+
5345+ // Worst-status exit: auth escalates (a rejected key fails every member the
5346+ // same way), then timeout/poll-error (7, resumable), then plain failure (1).
5347+ const authError = [ ...outcomes . values ( ) ] . find (
5348+ o =>
5349+ o . kind === 'error' &&
5350+ ( o . code === 'AUTH_REQUIRED' || o . code === 'AUTH_INVALID' || o . code === 'AUTH_FORBIDDEN' ) ,
5351+ ) ;
5352+ if ( authError !== undefined && authError . kind === 'error' ) {
5353+ throw new CLIError (
5354+ `Multi-run wait: authentication failed (${ authError . code } )` ,
5355+ authError . exitCode ,
5356+ ) ;
5357+ }
5358+ if ( timedOut > 0 || errors > 0 ) {
5359+ throw new CLIError (
5360+ `Multi-run wait: ${ timedOut } timed out, ${ errors } poll error(s) out of ${ results . length } runs` ,
5361+ 7 ,
5362+ ) ;
5363+ }
5364+ if ( failed > 0 ) {
5365+ throw new CLIError ( `Multi-run wait: ${ failed } run(s) finished non-passed` , 1 ) ;
5366+ }
5367+ return payload ;
5368+ }
5369+
51885370/**
51895371 * `test wait <run-id>` — M3.3 piece-3.
51905372 *
@@ -7963,26 +8145,52 @@ export function createTestCommand(deps: TestDeps = {}): Command {
79638145 } ) ;
79648146
79658147 test
7966- . command ( 'wait <run-id>' )
8148+ . command ( 'wait <run-id... >' )
79678149 . description (
7968- 'Wait for a run to reach a terminal status.\n' +
8150+ 'Wait for one or more runs to reach a terminal status.\n' +
8151+ '\nWith several run-ids the runs are polled concurrently under one shared\n' +
8152+ '--timeout and a {results, summary} envelope is printed (worst status wins\n' +
8153+ 'the exit code), so every re-attach hint the CLI prints can be pasted as\n' +
8154+ 'ONE command.\n' +
79698155 '\nExit codes:\n' +
79708156 ' 0 passed\n' +
79718157 ' 1 failed / blocked / cancelled\n' +
79728158 ' 3 auth error\n' +
79738159 ' 4 run not found\n' +
7974- ' 7 timeout — resume with: testsprite test wait <run-id>\n' +
8160+ ' 7 timeout — resume with: testsprite test wait <run-id... >\n' +
79758161 ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
79768162 '\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>' ,
79778163 )
79788164 . option ( '--timeout <s>' , `max seconds to wait (1–3600, default ${ DEFAULT_RUN_TIMEOUT_SECONDS } )` )
8165+ . option (
8166+ '--max-concurrency <n>' ,
8167+ 'with several run-ids, max concurrent polls (1-100, default: 10)' ,
8168+ )
79798169 . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7980- . action ( async ( runId : string , cmdOpts : WaitFlagOpts , command : Command ) => {
7981- await runTestWait (
8170+ . action ( async ( runIds : string [ ] , cmdOpts : WaitFlagOpts , command : Command ) => {
8171+ // One id keeps the historical single-run path byte-identical (same
8172+ // output shape, same exit codes); two or more fan out.
8173+ if ( runIds . length === 1 ) {
8174+ await runTestWait (
8175+ {
8176+ ...resolveCommonOptions ( command ) ,
8177+ runId : runIds [ 0 ] ! ,
8178+ timeoutSeconds : parseTimeoutFlag ( cmdOpts . timeout , 'timeout' ) ,
8179+ } ,
8180+ deps ,
8181+ ) ;
8182+ return ;
8183+ }
8184+ const maxConcurrency = parseNumericFlag ( cmdOpts . maxConcurrency , 'max-concurrency' ) ?? 10 ;
8185+ if ( ! Number . isInteger ( maxConcurrency ) || maxConcurrency < 1 || maxConcurrency > 100 ) {
8186+ throw localValidationError ( 'max-concurrency' , 'must be an integer between 1 and 100' ) ;
8187+ }
8188+ await runTestWaitMany (
79828189 {
79838190 ...resolveCommonOptions ( command ) ,
7984- runId ,
8191+ runIds ,
79858192 timeoutSeconds : parseTimeoutFlag ( cmdOpts . timeout , 'timeout' ) ,
8193+ maxConcurrency,
79868194 } ,
79878195 deps ,
79888196 ) ;
@@ -8142,6 +8350,7 @@ interface RunFlagOpts {
81428350
81438351interface WaitFlagOpts {
81448352 timeout ?: string ;
8353+ maxConcurrency ?: string ;
81458354}
81468355
81478356interface RerunFlagOpts {
0 commit comments