@@ -9,7 +9,8 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
99import { tmpdir } from 'node:os' ;
1010import { join } from 'node:path' ;
1111import { describe , expect , it } from 'vitest' ;
12- import { ApiError , RequestTimeoutError } from '../lib/errors.js' ;
12+ import { ApiError , InterruptError , RequestTimeoutError } from '../lib/errors.js' ;
13+ import { ShutdownController } from '../lib/interrupt.js' ;
1314import type { RunResponse , RerunResponse , BatchRerunResponse } from '../lib/runs.types.js' ;
1415import type { FetchImpl } from '../lib/http.js' ;
1516import { runTestRerun , resolveWaitRequestTimeoutMs } from './test.js' ;
@@ -4952,3 +4953,98 @@ describe('[finding-4] single FE rerun --wait: TimeoutError writes partial JSON t
49524953 void fetchCallCount ;
49534954 } ) ;
49544955} ) ;
4956+
4957+ // ---------------------------------------------------------------------------
4958+ // DEV-331 piece 1 — graceful detach during batch rerun --wait (SIG-6)
4959+ // ---------------------------------------------------------------------------
4960+
4961+ describe ( 'R-BAT: batch rerun --wait — InterruptError partial lists all dispatched runIds (DEV-331)' , ( ) => {
4962+ it ( 'interrupt mid fan-out → stdout partial covers every accepted runId, honest stderr, exit 130' , async ( ) => {
4963+ const creds = makeCreds ( ) ;
4964+ const shutdown = new ShutdownController ( ) ;
4965+ const batchResp : BatchRerunResponse = {
4966+ accepted : [
4967+ { testId : 'test_1' , runId : 'run_b1' , enqueuedAt : '2026-06-03T10:00:00.000Z' } ,
4968+ { testId : 'test_2' , runId : 'run_b2' , enqueuedAt : '2026-06-03T10:00:00.000Z' } ,
4969+ ] ,
4970+ deferred : [ ] ,
4971+ conflicts : [ ] ,
4972+ closure : { byProject : [ ] } ,
4973+ } ;
4974+
4975+ // Batch trigger resolves; every run poll hangs until the composed signal aborts.
4976+ const fetchImpl : FetchImpl = ( async ( input : unknown , init : RequestInit = { } ) => {
4977+ const url =
4978+ typeof input === 'string'
4979+ ? input
4980+ : input instanceof URL
4981+ ? input . toString ( )
4982+ : ( input as { url : string } ) . url ;
4983+ if ( url . includes ( '/tests/batch/rerun' ) ) {
4984+ return new Response ( JSON . stringify ( batchResp ) , {
4985+ status : 202 ,
4986+ headers : { 'content-type' : 'application/json' } ,
4987+ } ) ;
4988+ }
4989+ return new Promise < Response > ( ( _resolve , reject ) => {
4990+ const signal = init . signal ;
4991+ const rejectWithReason = ( ) : void => {
4992+ const reason : unknown = signal ?. reason ;
4993+ reject ( reason instanceof Error ? reason : new Error ( 'aborted' ) ) ;
4994+ } ;
4995+ if ( signal ?. aborted ) {
4996+ rejectWithReason ( ) ;
4997+ return ;
4998+ }
4999+ signal ?. addEventListener ( 'abort' , rejectWithReason , { once : true } ) ;
5000+ } ) ;
5001+ } ) as FetchImpl ;
5002+
5003+ const stdoutLines : string [ ] = [ ] ;
5004+ const stderrLines : string [ ] = [ ] ;
5005+ const pending = runTestRerun (
5006+ {
5007+ testIds : [ 'test_1' , 'test_2' ] ,
5008+ all : false ,
5009+ wait : true ,
5010+ timeoutSeconds : 600 ,
5011+ autoHeal : false ,
5012+ autoHealExplicit : false ,
5013+ skipDependencies : false ,
5014+ maxConcurrency : 10 ,
5015+ output : 'json' ,
5016+ profile : 'default' ,
5017+ dryRun : false ,
5018+ debug : false ,
5019+ verbose : false ,
5020+ } ,
5021+ {
5022+ ...creds ,
5023+ sleep : instantSleep ,
5024+ fetchImpl,
5025+ stdout : line => stdoutLines . push ( line ) ,
5026+ stderr : line => stderrLines . push ( line ) ,
5027+ shutdown,
5028+ } ,
5029+ ) ;
5030+ setTimeout ( ( ) => shutdown . interrupt ( 'SIGINT' ) , 10 ) ;
5031+
5032+ const err = await pending . catch ( e => e ) ;
5033+ expect ( err ) . toBeInstanceOf ( InterruptError ) ;
5034+ expect ( ( err as InterruptError ) . exitCode ) . toBe ( 130 ) ;
5035+
5036+ // SIG-6: the partial lists ALL dispatched runIds, marked running.
5037+ const stdoutJson = JSON . parse ( stdoutLines . join ( '\n' ) ) as {
5038+ accepted : Array < { runId : string ; status : string } > ;
5039+ } ;
5040+ const byRunId = new Map ( stdoutJson . accepted . map ( r => [ r . runId , r . status ] ) ) ;
5041+ expect ( byRunId . get ( 'run_b1' ) ) . toBe ( 'running' ) ;
5042+ expect ( byRunId . get ( 'run_b2' ) ) . toBe ( 'running' ) ;
5043+
5044+ const stderrBlock = stderrLines . join ( '\n' ) ;
5045+ expect ( stderrBlock ) . toContain ( 'Interrupted (SIGINT)' ) ;
5046+ expect ( stderrBlock ) . toContain ( 'billing' ) ;
5047+ expect ( stderrBlock ) . toContain ( 'run_b1' ) ;
5048+ expect ( stderrBlock ) . toContain ( 'run_b2' ) ;
5049+ } ) ;
5050+ } ) ;
0 commit comments