Skip to content

Commit c108903

Browse files
merge upstream/main into fix/code-get-empty-out-and-code-file-bom
Resolve conflicts in test.ts / test.test.ts by combining upstream's atomic temp-file --out writes with this branch's empty-code rejection (VALIDATION_ERROR exit 5) and BOM stripping. Update the pre-existing-file empty-code regression to expect exit 5 while still asserting the file is untouched.
2 parents f7b8d55 + 15e95de commit c108903

13 files changed

Lines changed: 1229 additions & 223 deletions

src/commands/test.rerun.spec.ts

Lines changed: 166 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)