@@ -5331,6 +5331,188 @@ export async function runTestRun(
53315331 return finalRun ;
53325332}
53335333
5334+ /** One row of the `test wait <run-id...>` multi-run payload. */
5335+ export interface CliMultiWaitResult {
5336+ runId : string ;
5337+ /** Terminal run status, or 'timeout', or 'error:<CODE>' when the poll failed. */
5338+ status : string ;
5339+ /** Test the run belongs to, when the poll observed it. */
5340+ testId ?: string ;
5341+ }
5342+
5343+ export interface RunTestWaitManyOptions extends CommonOptions {
5344+ runIds : string [ ] ;
5345+ timeoutSeconds : number ;
5346+ maxConcurrency : number ;
5347+ }
5348+
5349+ /**
5350+ * `test wait <run-id...>` with two or more ids: attach to N already-dispatched
5351+ * runs in ONE invocation. This closes the loop the CLI itself opens: every
5352+ * batch/closure timeout prints one `testsprite test wait <runId>` hint PER
5353+ * member, which previously meant N sequential blocking invocations. The runs
5354+ * are polled concurrently under a bounded pool with ONE shared deadline
5355+ * (`--timeout` bounds the whole invocation, not each member), each member's
5356+ * poll is total (a transient error on one run never discards the others), and
5357+ * the exit code is the worst status across members: auth errors escalate to
5358+ * exit 3, any timeout or poll error exits 7, any non-passed terminal exits 1.
5359+ * Distinct from a run journal (issue #80): no persistence, just N known ids.
5360+ */
5361+ export async function runTestWaitMany (
5362+ opts : RunTestWaitManyOptions ,
5363+ deps : TestDeps = { } ,
5364+ ) : Promise < { results : CliMultiWaitResult [ ] ; summary : Record < string , number > } > {
5365+ const out = makeOutput ( opts . output , deps ) ;
5366+ const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
5367+
5368+ if ( opts . dryRun ) {
5369+ emitDryRunBanner ( stderrFn ) ;
5370+ const results : CliMultiWaitResult [ ] = opts . runIds . map ( runId => ( {
5371+ runId,
5372+ status : 'passed' ,
5373+ } ) ) ;
5374+ const payload = {
5375+ results,
5376+ summary : { total : results . length , passed : results . length , failed : 0 , timedOut : 0 , errors : 0 } ,
5377+ } ;
5378+ out . print ( payload , ( ) => results . map ( r => `${ r . runId } ${ r . status } ` ) . join ( '\n' ) ) ;
5379+ return payload ;
5380+ }
5381+
5382+ const client = makeClient (
5383+ { ...opts , requestTimeoutMs : resolveWaitRequestTimeoutMs ( { ...opts , wait : true } ) } ,
5384+ deps ,
5385+ ) ;
5386+ const ticker = createTicker ( stderrFn , opts . output === 'json' ? false : undefined ) ;
5387+
5388+ // One shared deadline across every member (the whole point of the shared
5389+ // pool: `--timeout 600` means the invocation ends within ~600s, not
5390+ // 600s x ceil(N/concurrency)).
5391+ const deadlineMs = Date . now ( ) + opts . timeoutSeconds * 1000 ;
5392+
5393+ type WaitOutcome =
5394+ | { kind : 'result' ; run : RunResponse }
5395+ | { kind : 'timeout' }
5396+ | { kind : 'error' ; code : string ; exitCode : number } ;
5397+
5398+ const pollOne = async ( runId : string ) : Promise < WaitOutcome > => {
5399+ const resolveAlternate = makeBackendWaitFallback ( {
5400+ client,
5401+ resolveTestId : run => run . testId ,
5402+ resolveNotBefore : run => run . createdAt ,
5403+ onResolved : ( ) => undefined ,
5404+ } ) ;
5405+ try {
5406+ const run = await pollRunUntilTerminal ( client , runId , {
5407+ timeoutSeconds : Math . max ( 1 , Math . ceil ( ( deadlineMs - Date . now ( ) ) / 1000 ) ) ,
5408+ sleep : deps . sleep ,
5409+ onTransition : opts . verbose ? ( msg : string ) => stderrFn ( `[verbose] ${ msg } ` ) : undefined ,
5410+ onTick : ( run , elapsedMs ) => {
5411+ const elapsed = Math . round ( elapsedMs / 1000 ) ;
5412+ ticker . update ( `Run ${ run . runId } — ${ run . status } (elapsed=${ elapsed } s)` ) ;
5413+ } ,
5414+ resolveAlternate,
5415+ } ) ;
5416+ return { kind : 'result' , run } ;
5417+ } catch ( err ) {
5418+ if ( err instanceof TimeoutError ) return { kind : 'timeout' } ;
5419+ if ( err instanceof RequestTimeoutError ) throw err ;
5420+ if ( err instanceof ApiError ) return { kind : 'error' , code : err . code , exitCode : err . exitCode } ;
5421+ return { kind : 'error' , code : 'TRANSPORT' , exitCode : 10 } ;
5422+ }
5423+ } ;
5424+
5425+ const outcomes = new Map < string , WaitOutcome > ( ) ;
5426+ let inFlight = 0 ;
5427+ let nextIdx = 0 ;
5428+ try {
5429+ await new Promise < void > ( ( resolve , reject ) => {
5430+ const startNext = ( ) : void => {
5431+ while ( inFlight < opts . maxConcurrency && nextIdx < opts . runIds . length ) {
5432+ const runId = opts . runIds [ nextIdx ++ ] ! ;
5433+ inFlight ++ ;
5434+ pollOne ( runId )
5435+ . then ( outcome => {
5436+ outcomes . set ( runId , outcome ) ;
5437+ inFlight -- ;
5438+ startNext ( ) ;
5439+ if ( inFlight === 0 && nextIdx >= opts . runIds . length ) resolve ( ) ;
5440+ } )
5441+ // pollOne is total except for RequestTimeoutError (handled below).
5442+ . catch ( reject ) ;
5443+ }
5444+ } ;
5445+ startNext ( ) ;
5446+ if ( opts . runIds . length === 0 ) resolve ( ) ;
5447+ } ) ;
5448+ } catch ( fanOutErr ) {
5449+ if ( fanOutErr instanceof RequestTimeoutError ) {
5450+ // Same contract as the batch pollers: leave stdout parseable before
5451+ // exiting 7 — every dispatched id is re-attachable in one command.
5452+ ticker . finalize ( 'Multi-run wait — request timed out' ) ;
5453+ const partial = {
5454+ results : opts . runIds . map ( runId => ( { runId, status : 'running' } ) ) ,
5455+ summary : { total : opts . runIds . length } ,
5456+ } ;
5457+ out . print ( partial , ( ) => partial . results . map ( r => `${ r . runId } running` ) . join ( '\n' ) ) ;
5458+ stderrFn ( `Re-attach with: testsprite test wait ${ opts . runIds . join ( ' ' ) } ` ) ;
5459+ }
5460+ throw fanOutErr ;
5461+ }
5462+ ticker . finalize ( ) ;
5463+
5464+ const results : CliMultiWaitResult [ ] = opts . runIds . map ( runId => {
5465+ const outcome = outcomes . get ( runId ) ;
5466+ if ( outcome === undefined || outcome . kind === 'timeout' ) return { runId, status : 'timeout' } ;
5467+ if ( outcome . kind === 'error' ) return { runId, status : `error:${ outcome . code } ` } ;
5468+ return { runId, status : outcome . run . status , testId : outcome . run . testId } ;
5469+ } ) ;
5470+ const passed = results . filter ( r => r . status === 'passed' ) . length ;
5471+ const timedOut = results . filter ( r => r . status === 'timeout' ) . length ;
5472+ const errors = results . filter ( r => r . status . startsWith ( 'error:' ) ) . length ;
5473+ const failed = results . length - passed - timedOut - errors ;
5474+ const payload = {
5475+ results,
5476+ summary : { total : results . length , passed, failed, timedOut, errors } ,
5477+ } ;
5478+ out . print ( payload , ( ) =>
5479+ [
5480+ ...results . map ( r => `${ r . runId } ${ r . status } ` ) ,
5481+ '' ,
5482+ `${ passed } /${ results . length } passed, ${ failed } failed/blocked, ${ timedOut } timed out, ${ errors } poll errors` ,
5483+ ] . join ( '\n' ) ,
5484+ ) ;
5485+
5486+ const timedOutIds = results . filter ( r => r . status === 'timeout' ) . map ( r => r . runId ) ;
5487+ if ( timedOutIds . length > 0 ) {
5488+ stderrFn ( `Re-attach with: testsprite test wait ${ timedOutIds . join ( ' ' ) } ` ) ;
5489+ }
5490+
5491+ // Worst-status exit: auth escalates (a rejected key fails every member the
5492+ // same way), then timeout/poll-error (7, resumable), then plain failure (1).
5493+ const authError = [ ...outcomes . values ( ) ] . find (
5494+ o =>
5495+ o . kind === 'error' &&
5496+ ( o . code === 'AUTH_REQUIRED' || o . code === 'AUTH_INVALID' || o . code === 'AUTH_FORBIDDEN' ) ,
5497+ ) ;
5498+ if ( authError !== undefined && authError . kind === 'error' ) {
5499+ throw new CLIError (
5500+ `Multi-run wait: authentication failed (${ authError . code } )` ,
5501+ authError . exitCode ,
5502+ ) ;
5503+ }
5504+ if ( timedOut > 0 || errors > 0 ) {
5505+ throw new CLIError (
5506+ `Multi-run wait: ${ timedOut } timed out, ${ errors } poll error(s) out of ${ results . length } runs` ,
5507+ 7 ,
5508+ ) ;
5509+ }
5510+ if ( failed > 0 ) {
5511+ throw new CLIError ( `Multi-run wait: ${ failed } run(s) finished non-passed` , 1 ) ;
5512+ }
5513+ return payload ;
5514+ }
5515+
53345516/**
53355517 * `test wait <run-id>` — M3.3 piece-3.
53365518 *
@@ -8140,26 +8322,52 @@ export function createTestCommand(deps: TestDeps = {}): Command {
81408322 } ) ;
81418323
81428324 test
8143- . command ( 'wait <run-id>' )
8325+ . command ( 'wait <run-id... >' )
81448326 . description (
8145- 'Wait for a run to reach a terminal status.\n' +
8327+ 'Wait for one or more runs to reach a terminal status.\n' +
8328+ '\nWith several run-ids the runs are polled concurrently under one shared\n' +
8329+ '--timeout and a {results, summary} envelope is printed (worst status wins\n' +
8330+ 'the exit code), so every re-attach hint the CLI prints can be pasted as\n' +
8331+ 'ONE command.\n' +
81468332 '\nExit codes:\n' +
81478333 ' 0 passed\n' +
81488334 ' 1 failed / blocked / cancelled\n' +
81498335 ' 3 auth error\n' +
81508336 ' 4 run not found\n' +
8151- ' 7 timeout — resume with: testsprite test wait <run-id>\n' +
8337+ ' 7 timeout — resume with: testsprite test wait <run-id... >\n' +
81528338 ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' +
81538339 '\nOn failure/blocked/cancelled, run: testsprite test artifact get <run-id>' ,
81548340 )
81558341 . option ( '--timeout <s>' , `max seconds to wait (1–3600, default ${ DEFAULT_RUN_TIMEOUT_SECONDS } )` )
8342+ . option (
8343+ '--max-concurrency <n>' ,
8344+ 'with several run-ids, max concurrent polls (1-100, default: 10)' ,
8345+ )
81568346 . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
8157- . action ( async ( runId : string , cmdOpts : WaitFlagOpts , command : Command ) => {
8158- await runTestWait (
8347+ . action ( async ( runIds : string [ ] , cmdOpts : WaitFlagOpts , command : Command ) => {
8348+ // One id keeps the historical single-run path byte-identical (same
8349+ // output shape, same exit codes); two or more fan out.
8350+ if ( runIds . length === 1 ) {
8351+ await runTestWait (
8352+ {
8353+ ...resolveCommonOptions ( command ) ,
8354+ runId : runIds [ 0 ] ! ,
8355+ timeoutSeconds : parseTimeoutFlag ( cmdOpts . timeout , 'timeout' ) ,
8356+ } ,
8357+ deps ,
8358+ ) ;
8359+ return ;
8360+ }
8361+ const maxConcurrency = parseNumericFlag ( cmdOpts . maxConcurrency , 'max-concurrency' ) ?? 10 ;
8362+ if ( ! Number . isInteger ( maxConcurrency ) || maxConcurrency < 1 || maxConcurrency > 100 ) {
8363+ throw localValidationError ( 'max-concurrency' , 'must be an integer between 1 and 100' ) ;
8364+ }
8365+ await runTestWaitMany (
81598366 {
81608367 ...resolveCommonOptions ( command ) ,
8161- runId ,
8368+ runIds ,
81628369 timeoutSeconds : parseTimeoutFlag ( cmdOpts . timeout , 'timeout' ) ,
8370+ maxConcurrency,
81638371 } ,
81648372 deps ,
81658373 ) ;
@@ -8545,6 +8753,7 @@ interface RunFlagOpts {
85458753
85468754interface WaitFlagOpts {
85478755 timeout ?: string ;
8756+ maxConcurrency ?: string ;
85488757}
85498758
85508759interface RerunFlagOpts {
0 commit comments