From 1331924a16e531522887e290819a6009e07ff620 Mon Sep 17 00:00:00 2001 From: Rahul Joshi <186129212+crypticsaiyan@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:49:49 +0530 Subject: [PATCH] fix(rerun): preserve exit code and escalate auth errors in batch rerun pollAccepted in runTestRerun hardcoded exitCode:1 on ApiError, causing the auth-escalation find(r => r.error?.exitCode === 3) to always return undefined -- auth failures silently exited 1 instead of 3. - preserve err.exitCode in pollAccepted (mirrors runTestRunAll fix) - add auth escalation block before generic exit-1 throw - bound initial chunk idempotency key to <=256 chars (mirrors retry path) --- src/commands/test.rerun.spec.ts | 482 ++++++++++++++++++++++++++++++++ src/commands/test.ts | 23 +- 2 files changed, 503 insertions(+), 2 deletions(-) diff --git a/src/commands/test.rerun.spec.ts b/src/commands/test.rerun.spec.ts index e33634b..37f413f 100644 --- a/src/commands/test.rerun.spec.ts +++ b/src/commands/test.rerun.spec.ts @@ -4475,3 +4475,485 @@ describe('rerun --wait — dashboardUrl on terminal output', () => { ); }); }); + +// --------------------------------------------------------------------------- +// [fix-exitcode] pollAccepted preserves ApiError exit codes (not hardcoded 1) +// --------------------------------------------------------------------------- + +describe('[fix-exitcode] polling error exit codes preserved in batch rerun results', () => { + it('AUTH_REQUIRED during polling → batch escalates to exitCode 3', async () => { + const creds = makeCreds(); + const passRun = makeTerminalRun('run_pass_a1', 'passed'); + const batchResp: BatchRerunResponse = { + accepted: [ + { testId: 'test_1', runId: 'run_auth_fail', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_2', runId: 'run_pass_a1', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/batch/rerun')) return { status: 202, body: batchResp }; + if (url.includes('/runs/run_auth_fail')) return errorBody('AUTH_REQUIRED'); + if (url.includes('/runs/run_pass_a1')) return { body: passRun }; + return errorBody('NOT_FOUND'); + }); + + const err = await runTestRerun( + { + testIds: ['test_1', 'test_2'], + all: false, + wait: true, + timeoutSeconds: 10, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 5, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ).catch(e => e as { exitCode?: number; message?: string }); + + expect((err as { exitCode?: number }).exitCode).toBe(3); + }); + + it('RATE_LIMITED during polling → non-auth, batch exits 1', async () => { + const creds = makeCreds(); + const passRun = makeTerminalRun('run_pass_a2', 'passed'); + const batchResp: BatchRerunResponse = { + accepted: [ + { testId: 'test_1', runId: 'run_rl', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_2', runId: 'run_pass_a2', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/batch/rerun')) return { status: 202, body: batchResp }; + if (url.includes('/runs/run_rl')) return errorBody('RATE_LIMITED'); + if (url.includes('/runs/run_pass_a2')) return { body: passRun }; + return errorBody('NOT_FOUND'); + }); + + const err = await runTestRerun( + { + testIds: ['test_1', 'test_2'], + all: false, + wait: true, + timeoutSeconds: 10, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 5, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ).catch(e => e); + + expect((err as { exitCode?: number }).exitCode).toBe(1); + }); + + it('NOT_FOUND during run polling → non-auth, batch exits 1', async () => { + const creds = makeCreds(); + const passRun = makeTerminalRun('run_pass_a3', 'passed'); + const batchResp: BatchRerunResponse = { + accepted: [ + { testId: 'test_1', runId: 'run_nf', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_2', runId: 'run_pass_a3', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/batch/rerun')) return { status: 202, body: batchResp }; + if (url.includes('/runs/run_nf')) return errorBody('NOT_FOUND'); + if (url.includes('/runs/run_pass_a3')) return { body: passRun }; + return errorBody('NOT_FOUND'); + }); + + const err = await runTestRerun( + { + testIds: ['test_1', 'test_2'], + all: false, + wait: true, + timeoutSeconds: 10, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 5, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ).catch(e => e); + + expect((err as { exitCode?: number }).exitCode).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// [fix-auth-escalation] batch auth failure escalates to exit 3 +// --------------------------------------------------------------------------- + +describe('[fix-auth-escalation] auth error in batch rerun polling escalates to exit 3', () => { + it('auth failure in batch poll → batch exits 3, not 1', async () => { + const creds = makeCreds(); + const passRun = makeTerminalRun('run_other', 'passed'); + const batchResp: BatchRerunResponse = { + accepted: [ + { testId: 'test_auth', runId: 'run_auth', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_other', runId: 'run_other', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/batch/rerun')) return { status: 202, body: batchResp }; + if (url.includes('/runs/run_auth')) return errorBody('AUTH_REQUIRED'); + if (url.includes('/runs/run_other')) return { body: passRun }; + return errorBody('NOT_FOUND'); + }); + + const err = await runTestRerun( + { + testIds: ['test_auth', 'test_other'], + all: false, + wait: true, + timeoutSeconds: 10, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 5, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ).catch(e => e); + + expect((err as { exitCode?: number }).exitCode).toBe(3); + }); + + it('mixed batch: one pass, one auth failure → exits 3 (auth wins)', async () => { + const creds = makeCreds(); + const batchResp: BatchRerunResponse = { + accepted: [ + { testId: 'test_1', runId: 'run_pass', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_2', runId: 'run_auth2', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + const passRun = makeTerminalRun('run_pass', 'passed'); + passRun.testId = 'test_1'; + + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/batch/rerun')) return { status: 202, body: batchResp }; + if (url.includes('/runs/run_pass')) return { body: passRun }; + if (url.includes('/runs/run_auth2')) return errorBody('AUTH_REQUIRED'); + return errorBody('NOT_FOUND'); + }); + + const err = await runTestRerun( + { + testIds: ['test_1', 'test_2'], + all: false, + wait: true, + timeoutSeconds: 10, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 5, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ).catch(e => e); + + expect((err as { exitCode?: number }).exitCode).toBe(3); + expect((err as { message?: string }).message).toMatch(/auth error/i); + }); + + it('non-auth failure → exits 1 (no escalation)', async () => { + const creds = makeCreds(); + const failRun = makeTerminalRun('run_fail', 'failed'); + failRun.testId = 'test_1'; + const passRun = makeTerminalRun('run_pass_c3', 'passed'); + passRun.testId = 'test_2'; + const batchResp: BatchRerunResponse = { + accepted: [ + { testId: 'test_1', runId: 'run_fail', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_2', runId: 'run_pass_c3', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + + const fetchImpl = makeFetch(url => { + if (url.includes('/tests/batch/rerun')) return { status: 202, body: batchResp }; + if (url.includes('/runs/run_fail')) return { body: failRun }; + if (url.includes('/runs/run_pass_c3')) return { body: passRun }; + return errorBody('NOT_FOUND'); + }); + + const err = await runTestRerun( + { + testIds: ['test_1', 'test_2'], + all: false, + wait: true, + timeoutSeconds: 10, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 5, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ).catch(e => e); + + expect((err as { exitCode?: number }).exitCode).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// [fix-D4] initial chunk idempotency key bounded to ≤256 chars +// --------------------------------------------------------------------------- + +describe('[fix-D4] initial chunk dispatch idempotency key bounded to 256 chars', () => { + it('short key with multiple chunks passes through unchanged', async () => { + const creds = makeCreds(); + const receivedKeys: string[] = []; + + // 51 test IDs forces 2 chunks (MAX_BATCH_RERUN_IDS = 50) + const testIds = Array.from({ length: 51 }, (_, i) => `test_${i}`); + const batchResp: BatchRerunResponse = { + accepted: testIds.slice(0, 50).map(id => ({ + testId: id, + runId: `run_${id}`, + enqueuedAt: '2026-06-03T10:00:00.000Z', + })), + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + const batchResp2: BatchRerunResponse = { + accepted: [ + { + testId: testIds[50]!, + runId: `run_${testIds[50]}`, + enqueuedAt: '2026-06-03T10:00:00.000Z', + }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + let callCount = 0; + + const fetchImpl = makeFetch((url, init) => { + if (url.includes('/tests/batch/rerun')) { + const h = new Headers(init.headers ?? {}); + const key = h.get('idempotency-key') ?? ''; + receivedKeys.push(key); + callCount++; + return { status: 202, body: callCount === 1 ? batchResp : batchResp2 }; + } + return errorBody('NOT_FOUND'); + }); + + await runTestRerun( + { + testIds, + all: false, + wait: false, + timeoutSeconds: 600, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 10, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + idempotencyKey: 'short-key', + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ); + + expect(receivedKeys).toHaveLength(2); + expect(receivedKeys[0]).toBe('short-key:chunk0'); + expect(receivedKeys[1]).toBe('short-key:chunk1'); + expect(receivedKeys[0]!.length).toBeLessThanOrEqual(256); + expect(receivedKeys[1]!.length).toBeLessThanOrEqual(256); + }); + + it('249-char key + :chunk0 suffix would exceed 256 → key truncated to keep total ≤256', async () => { + const creds = makeCreds(); + const receivedKeys: string[] = []; + + // key is 249 chars; `:chunk0` is 7 chars → 256 total (edge case, fits exactly) + const longKey = 'k'.repeat(249); + const testIds = Array.from({ length: 51 }, (_, i) => `test_${i}`); + const batchResp: BatchRerunResponse = { + accepted: testIds.slice(0, 50).map(id => ({ + testId: id, + runId: `run_${id}`, + enqueuedAt: '2026-06-03T10:00:00.000Z', + })), + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + const batchResp2: BatchRerunResponse = { + accepted: [ + { + testId: testIds[50]!, + runId: `run_${testIds[50]}`, + enqueuedAt: '2026-06-03T10:00:00.000Z', + }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + let callCount = 0; + + const fetchImpl = makeFetch((url, init) => { + if (url.includes('/tests/batch/rerun')) { + const h = new Headers(init.headers ?? {}); + receivedKeys.push(h.get('idempotency-key') ?? ''); + callCount++; + return { status: 202, body: callCount === 1 ? batchResp : batchResp2 }; + } + return errorBody('NOT_FOUND'); + }); + + await runTestRerun( + { + testIds, + all: false, + wait: false, + timeoutSeconds: 600, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 10, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + idempotencyKey: longKey, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ); + + expect(receivedKeys).toHaveLength(2); + for (const key of receivedKeys) { + expect(key.length).toBeLessThanOrEqual(256); + } + // suffix must be preserved + expect(receivedKeys[0]).toMatch(/:chunk0$/); + expect(receivedKeys[1]).toMatch(/:chunk1$/); + }); + + it('256-char key + :chunk0 suffix → base truncated so total is exactly 256', async () => { + const creds = makeCreds(); + const receivedKeys: string[] = []; + + // Max-length user key: 256 chars. `:chunk0` = 7 chars → need to truncate base to 249. + const maxKey = 'x'.repeat(256); + const testIds = Array.from({ length: 51 }, (_, i) => `test_${i}`); + const batchResp: BatchRerunResponse = { + accepted: testIds.slice(0, 50).map(id => ({ + testId: id, + runId: `run_${id}`, + enqueuedAt: '2026-06-03T10:00:00.000Z', + })), + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + const batchResp2: BatchRerunResponse = { + accepted: [ + { + testId: testIds[50]!, + runId: `run_${testIds[50]}`, + enqueuedAt: '2026-06-03T10:00:00.000Z', + }, + ], + deferred: [], + conflicts: [], + closure: { byProject: [] }, + }; + let callCount = 0; + + const fetchImpl = makeFetch((url, init) => { + if (url.includes('/tests/batch/rerun')) { + const h = new Headers(init.headers ?? {}); + receivedKeys.push(h.get('idempotency-key') ?? ''); + callCount++; + return { status: 202, body: callCount === 1 ? batchResp : batchResp2 }; + } + return errorBody('NOT_FOUND'); + }); + + await runTestRerun( + { + testIds, + all: false, + wait: false, + timeoutSeconds: 600, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 10, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + idempotencyKey: maxKey, + }, + { ...creds, sleep: instantSleep, fetchImpl, stdout: () => {}, stderr: () => {} }, + ); + + expect(receivedKeys).toHaveLength(2); + for (const key of receivedKeys) { + expect(key.length).toBeLessThanOrEqual(256); + } + expect(receivedKeys[0]).toMatch(/:chunk0$/); + expect(receivedKeys[1]).toMatch(/:chunk1$/); + }); +}); diff --git a/src/commands/test.ts b/src/commands/test.ts index e254958..9180456 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -6149,7 +6149,12 @@ export async function runTestRerun( try { chunkResponses = await Promise.all( chunks.map((chunk, idx) => { - const chunkKey = chunks.length === 1 ? idempotencyKey : `${idempotencyKey}:chunk${idx}`; + const chunkSuffix = chunks.length === 1 ? '' : `:chunk${idx}`; + const chunkBase = + chunkSuffix.length > 0 && idempotencyKey.length + chunkSuffix.length > 256 + ? idempotencyKey.slice(0, 256 - chunkSuffix.length) + : idempotencyKey; + const chunkKey = `${chunkBase}${chunkSuffix}`; return client.triggerBatchRerun( { source: 'cli', @@ -6473,11 +6478,14 @@ export async function runTestRerun( }; } if (err instanceof ApiError) { + // Preserve the real exit code (AUTH_INVALID=3, RATE_LIMITED=11, …) so the + // batch exit-code aggregator can escalate auth failures correctly. Mirroring + // the identical fix already applied to runTestRunAll's pollFreshAccepted. return { testId: entry.testId, runId: entry.runId, status: 'error', - error: { code: err.code, message: err.message, exitCode: 1 }, + error: { code: err.code, message: err.message, exitCode: err.exitCode }, }; } throw err; @@ -6578,6 +6586,17 @@ export async function runTestRerun( } if (failed > 0) { + // Auth failure on any member is a batch-wide condition — the credential is + // bad, not the test. Propagate exit 3 so the operator fixes auth rather than + // chasing a "rerun failed" (exit 1). Mirrors the identical logic already + // applied to runTestRunAll lines 5462-5468. + const authErr = rerunResults.find(r => r.error?.exitCode === 3); + if (authErr) { + throw new CLIError( + `${failed} rerun${failed !== 1 ? 's' : ''} failed — auth error (${authErr.error?.code}): ${authErr.error?.message}`, + 3, + ); + } throw new CLIError(`${failed} rerun${failed !== 1 ? 's' : ''} failed.`, 1); }