@@ -2431,6 +2431,159 @@ describe('[fix-D] --all resolves >50 tests: chunked batch requests, aggregated r
24312431 // Exactly 50 → 1 request only
24322432 expect ( batchCallCount ) . toBe ( 1 ) ;
24332433 } ) ;
2434+
2435+ // Regression: chunked batch-rerun dispatched chunks via Promise.all, so
2436+ // when --all resolves >50 tests every chunk's request was in flight at
2437+ // once. BE producer/teardown closure dedup happens per-request, so two
2438+ // concurrent chunks sharing a project's producer could each independently
2439+ // trigger it. Chunks must be dispatched strictly one at a time.
2440+ it ( '60 tests → 2 chunks are dispatched sequentially, not concurrently' , async ( ) => {
2441+ const creds = makeCreds ( ) ;
2442+ const allTests = Array . from ( { length : 60 } , ( _ , i ) => ( {
2443+ ...FE_TEST ,
2444+ id : `test_seq_${ String ( i ) . padStart ( 3 , '0' ) } ` ,
2445+ } ) ) ;
2446+ const CHUNK_DELAY_MS = 40 ;
2447+ let activeBatchCalls = 0 ;
2448+ const activeAtStart : number [ ] = [ ] ;
2449+
2450+ type FetchInput2 = Parameters < typeof globalThis . fetch > [ 0 ] ;
2451+ const fetchImpl = ( async ( input : FetchInput2 , init : RequestInit = { } ) => {
2452+ const url =
2453+ typeof input === 'string'
2454+ ? input
2455+ : input instanceof URL
2456+ ? input . toString ( )
2457+ : ( input as { url : string } ) . url ;
2458+ if ( url . includes ( '/tests' ) && ! url . includes ( 'batch' ) && ! url . includes ( '/runs' ) ) {
2459+ return new Response ( JSON . stringify ( { items : allTests , nextToken : null } ) , {
2460+ status : 200 ,
2461+ headers : { 'content-type' : 'application/json' } ,
2462+ } ) ;
2463+ }
2464+ if ( url . includes ( '/tests/batch/rerun' ) ) {
2465+ activeBatchCalls ++ ;
2466+ activeAtStart . push ( activeBatchCalls ) ;
2467+ const body = JSON . parse ( init . body as string ) as { testIds : string [ ] } ;
2468+ await new Promise ( resolve => setTimeout ( resolve , CHUNK_DELAY_MS ) ) ;
2469+ activeBatchCalls -- ;
2470+ const accepted = body . testIds . map ( tid => ( {
2471+ testId : tid ,
2472+ runId : `run_${ tid } ` ,
2473+ enqueuedAt : '2026-06-03T10:00:00.000Z' ,
2474+ } ) ) ;
2475+ return new Response (
2476+ JSON . stringify ( {
2477+ accepted,
2478+ deferred : [ ] ,
2479+ conflicts : [ ] ,
2480+ closure : { byProject : [ ] } ,
2481+ } satisfies BatchRerunResponse ) ,
2482+ { status : 202 , headers : { 'content-type' : 'application/json' } } ,
2483+ ) ;
2484+ }
2485+ return new Response (
2486+ JSON . stringify ( {
2487+ error : { code : 'NOT_FOUND' , message : 'not found' , nextAction : '' , requestId : 'r1' } ,
2488+ } ) ,
2489+ { status : 404 , headers : { 'content-type' : 'application/json' } } ,
2490+ ) ;
2491+ } ) as typeof globalThis . fetch ;
2492+
2493+ await runTestRerun (
2494+ {
2495+ testIds : [ ] ,
2496+ all : true ,
2497+ projectId : 'project_abc' ,
2498+ wait : false ,
2499+ timeoutSeconds : 600 ,
2500+ autoHeal : false ,
2501+ autoHealExplicit : false ,
2502+ skipDependencies : false ,
2503+ maxConcurrency : 10 ,
2504+ output : 'json' ,
2505+ profile : 'default' ,
2506+ dryRun : false ,
2507+ debug : false ,
2508+ verbose : false ,
2509+ } ,
2510+ { ...creds , sleep : instantSleep , fetchImpl } ,
2511+ ) ;
2512+
2513+ expect ( activeAtStart ) . toEqual ( [ 1 , 1 ] ) ;
2514+ } ) ;
2515+
2516+ // Regression: even with sequential dispatch, defend the CLI's own
2517+ // accounting against a shared BE producer/teardown coming back accepted
2518+ // from more than one chunk (a different runId each time). Duplicate
2519+ // testIds must be deduped, not double-counted or double-polled.
2520+ it ( 'a testId accepted by two chunks is deduped, kept once, and warned about' , async ( ) => {
2521+ const creds = makeCreds ( ) ;
2522+ const allTests = Array . from ( { length : 60 } , ( _ , i ) => ( {
2523+ ...FE_TEST ,
2524+ id : `test_dup_${ String ( i ) . padStart ( 3 , '0' ) } ` ,
2525+ } ) ) ;
2526+ let batchCallCount = 0 ;
2527+ const stderrLines : string [ ] = [ ] ;
2528+
2529+ const fetchImpl = makeFetch ( ( url , init ) => {
2530+ if ( url . includes ( '/tests' ) && ! url . includes ( 'batch' ) && ! url . includes ( '/runs' ) ) {
2531+ return { body : { items : allTests , nextToken : null } } ;
2532+ }
2533+ if ( url . includes ( '/tests/batch/rerun' ) ) {
2534+ batchCallCount ++ ;
2535+ const body = JSON . parse ( init . body as string ) as { testIds : string [ ] } ;
2536+ const accepted = body . testIds . map ( tid => ( {
2537+ testId : tid ,
2538+ runId : `run_${ tid } _call${ batchCallCount } ` ,
2539+ enqueuedAt : '2026-06-03T10:00:00.000Z' ,
2540+ } ) ) ;
2541+ // Simulate a shared BE producer (not one of the 60 selected ids)
2542+ // that both chunks' server-side closure expansion independently
2543+ // decided to trigger, each with its own runId.
2544+ accepted . push ( {
2545+ testId : 'test_dup_producer' ,
2546+ runId : `run_producer_call${ batchCallCount } ` ,
2547+ enqueuedAt : '2026-06-03T10:00:00.000Z' ,
2548+ } ) ;
2549+ return {
2550+ status : 202 ,
2551+ body : {
2552+ accepted,
2553+ deferred : [ ] ,
2554+ conflicts : [ ] ,
2555+ closure : { byProject : [ ] } ,
2556+ } satisfies BatchRerunResponse ,
2557+ } ;
2558+ }
2559+ return errorBody ( 'NOT_FOUND' ) ;
2560+ } ) ;
2561+
2562+ await runTestRerun (
2563+ {
2564+ testIds : [ ] ,
2565+ all : true ,
2566+ projectId : 'project_abc' ,
2567+ wait : false ,
2568+ timeoutSeconds : 600 ,
2569+ autoHeal : false ,
2570+ autoHealExplicit : false ,
2571+ skipDependencies : false ,
2572+ maxConcurrency : 10 ,
2573+ output : 'json' ,
2574+ profile : 'default' ,
2575+ dryRun : false ,
2576+ debug : false ,
2577+ verbose : false ,
2578+ } ,
2579+ { ...creds , sleep : instantSleep , fetchImpl, stderr : line => stderrLines . push ( line ) } ,
2580+ ) ;
2581+
2582+ expect ( batchCallCount ) . toBe ( 2 ) ;
2583+ expect (
2584+ stderrLines . some ( l => l . includes ( 'triggered more than once' ) && l . includes ( '1 test' ) ) ,
2585+ ) . toBe ( true ) ;
2586+ } ) ;
24342587} ) ;
24352588
24362589// ---------------------------------------------------------------------------
@@ -3055,20 +3208,21 @@ describe('D3: batch rerun summary surfaces deferred + conflicts', () => {
30553208 it ( '--wait JSON summary includes deferred/conflicts counts (no silent undercount)' , async ( ) => {
30563209 const creds = makeCreds ( ) ;
30573210 // Initial dispatch: 1 accepted, 1 deferred, 1 conflict.
3058- // D3 retry loop will fire (opts.wait=true). All 3 retry attempts return the
3059- // same deferred entry so `deferred` never drains, and each retry's `accepted`
3060- // entry (same test_1 / run_b1) is merged in. After the loop, accepted has
3061- // 4 entries (1 original + 3 retries) and deferred still has 1 entry.
3211+ // D3 retry loop will fire (opts.wait=true). The retry request only ever
3212+ // re-asks about the still-deferred testId (test_deferred), so a
3213+ // realistic retry response never re-returns test_1 as newly accepted.
3214+ // All 3 retry attempts keep returning the same deferred entry so
3215+ // `deferred` never drains.
30623216 const initialBatchResp : BatchRerunResponse = {
30633217 accepted : [ { testId : 'test_1' , runId : 'run_b1' , enqueuedAt : '2026-06-03T10:00:00.000Z' } ] ,
30643218 deferred : [ { testId : 'test_deferred' , reason : 'rate_limited' } ] ,
30653219 conflicts : [ { testId : 'test_conf' , currentRunId : 'run_conf' } ] ,
30663220 closure : { byProject : [ ] } ,
30673221 } ;
3068- // Retry responses: keep returning 1 deferred + 1 accepted (same run) so
3069- // the loop exhausts MAX_DEFERRED_RETRIES and falls through.
3222+ // Retry responses: keep returning 1 deferred so the loop exhausts
3223+ // MAX_DEFERRED_RETRIES and falls through. No new accepted entries .
30703224 const retryBatchResp : BatchRerunResponse = {
3071- accepted : [ { testId : 'test_1' , runId : 'run_b1' , enqueuedAt : '2026-06-03T10:00:00.000Z' } ] ,
3225+ accepted : [ ] ,
30723226 deferred : [ { testId : 'test_deferred' , reason : 'rate_limited' } ] ,
30733227 conflicts : [ ] ,
30743228 closure : { byProject : [ ] } ,
@@ -3120,15 +3274,16 @@ describe('D3: batch rerun summary surfaces deferred + conflicts', () => {
31203274
31213275 const withSummary = printed . find ( p => p . summary ) ;
31223276 expect ( withSummary ) . toBeDefined ( ) ;
3123- // After D3 retries: accepted = 4 entries (same run_b1 merged 4 times);
3124- // all 4 poll as passed. deferred = 1 (still undrained). conflicts = 1 (from initial).
3277+ // After D3 retries: accepted = 1 entry (test_1 from the initial dispatch;
3278+ // retries never re-return it). deferred = 1 (still undrained). conflicts
3279+ // = 1 (from initial).
31253280 expect ( withSummary ! . summary ) . toMatchObject ( {
3126- passed : 4 ,
3281+ passed : 1 ,
31273282 failed : 0 ,
31283283 timedOut : 0 ,
31293284 deferred : 1 ,
31303285 conflicts : 1 ,
3131- total : 4 ,
3286+ total : 1 ,
31323287 } ) ;
31333288 } ) ;
31343289} ) ;
0 commit comments