diff --git a/src/commands/test.rerun.spec.ts b/src/commands/test.rerun.spec.ts index 129d859..9f16c4b 100644 --- a/src/commands/test.rerun.spec.ts +++ b/src/commands/test.rerun.spec.ts @@ -4820,326 +4820,38 @@ describe('rerun --wait — dashboardUrl on terminal output', () => { }); // --------------------------------------------------------------------------- -// [fix-exitcode] pollAccepted preserves ApiError exit codes (not hardcoded 1) +// Batch --all --wait fan-out: RequestTimeoutError must not leave stdout empty // --------------------------------------------------------------------------- -describe('[fix-exitcode] polling error exit codes preserved in batch rerun results', () => { - it('AUTH_REQUIRED during polling → batch escalates to exitCode 3', async () => { +describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out poll writes JSON stdout + exit 7', () => { + it('stdout contains accepted[] with runIds when member polls throw RequestTimeoutError', 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' }, + { testId: 'test_1', runId: 'run_b1', enqueuedAt: '2026-06-03T10:00:00.000Z' }, + { testId: 'test_2', runId: 'run_b2', 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 }; + if (url.includes('/tests/batch/rerun')) { + return { status: 202, body: batchResp }; + } + if (url.includes('/runs/')) { + throw new RequestTimeoutError(120000, 'req_timeout_batch_rerun'); + } return errorBody('NOT_FOUND'); }); + const stdoutLines: string[] = []; 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, + timeoutSeconds: 60, autoHeal: false, autoHealExplicit: false, skipDependencies: false, @@ -5149,154 +4861,23 @@ describe('[fix-D4] initial chunk dispatch idempotency key bounded to 256 chars', 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: fetchImpl as unknown as FetchImpl, + stdout: line => stdoutLines.push(line), + stderr: () => undefined, }, - { ...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[] = []; + ).catch(e => e); - // 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: [] }, + expect(err).toMatchObject({ exitCode: 7 }); + expect(stdoutLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(stdoutLines.join('\n')) as { + accepted: Array<{ testId: string; runId: string; status: string }>; }; - 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$/); + expect(parsed.accepted).toHaveLength(2); + expect(parsed.accepted.map(r => r.runId).sort()).toEqual(['run_b1', 'run_b2']); + expect(parsed.accepted.every(r => r.status === 'timeout')).toBe(true); }); }); diff --git a/src/commands/test.run.spec.ts b/src/commands/test.run.spec.ts index 2f2ea9d..c0ff083 100644 --- a/src/commands/test.run.spec.ts +++ b/src/commands/test.run.spec.ts @@ -5,7 +5,7 @@ * sleep injection is wired through `TestDeps.sleep` to avoid real delays. */ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Command } from 'commander'; @@ -3693,60 +3693,33 @@ describe('dashboardUrl on run completion', () => { }); }); -describe('runTestRunAll — JUnit report export', () => { - const BATCH_FRESH_RESP: BatchRunFreshResponse = { - accepted: [ - { testId: 'test_be_01', runId: 'run_fresh_01', enqueuedAt: '2026-06-09T10:00:00.000Z' }, - { testId: 'test_be_02', runId: 'run_fresh_02', enqueuedAt: '2026-06-09T10:00:01.000Z' }, - ], - conflicts: [], - deferred: [], - skippedFrontend: [], - skippedIntegration: [], - }; - - function makeTerminalRun(runId: string, testId: string, status: string): RunResponse { - return { - runId, - testId, - projectId: 'project_be', - userId: 'user_1', - status: status as RunResponse['status'], - source: 'cli', - createdAt: '2026-06-09T10:00:00.000Z', - startedAt: '2026-06-09T10:00:01.000Z', - finishedAt: '2026-06-09T10:00:30.000Z', - codeVersion: 'v1', - targetUrl: 'https://api.example.com', - createdFrom: 'cli', - failedStepIndex: null, - failureKind: null, - error: null, - videoUrl: null, - stepSummary: { - total: 3, - completed: 3, - passedCount: status === 'passed' ? 3 : 0, - failedCount: 0, - }, - }; - } +// --------------------------------------------------------------------------- +// Batch --all --wait fan-out: RequestTimeoutError must not leave stdout empty +// --------------------------------------------------------------------------- - it('--wait --report junit writes XML after polling', async () => { +describe('[finding-5] runTestRunAll --wait: RequestTimeoutError during fan-out poll writes JSON stdout + exit 7', () => { + it('stdout contains accepted[] with runIds when member polls throw RequestTimeoutError', async () => { const { credentialsPath } = makeCreds(); - const dir = mkdtempSync(join(tmpdir(), 'junit-run-all-')); - const reportPath = join(dir, 'results.xml'); + const batchResp: BatchRunFreshResponse = { + accepted: [ + { testId: 'test_be_01', runId: 'run_fresh_01', enqueuedAt: '2026-06-09T10:00:00.000Z' }, + { testId: 'test_be_02', runId: 'run_fresh_02', enqueuedAt: '2026-06-09T10:00:01.000Z' }, + ], + conflicts: [], + deferred: [], + skippedFrontend: [], + skippedIntegration: [], + }; const fetchImpl = makeFetch((url, init) => { - if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP }; - const runId = url.split('/runs/')[1]?.split('?')[0] ?? ''; - if (runId === 'run_fresh_01') - return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') }; - if (runId === 'run_fresh_02') - return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'passed') }; + if ((init.method ?? 'GET') === 'POST') return { body: batchResp }; + if (url.includes('/runs/')) { + throw new RequestTimeoutError(120000, 'req_timeout_batch_all'); + } return errorBody('NOT_FOUND'); }); + const stdoutLines: string[] = []; - await runTestRunAll( + const err = await runTestRunAll( { profile: 'default', output: 'json', @@ -3755,158 +3728,23 @@ describe('runTestRunAll — JUnit report export', () => { wait: true, timeoutSeconds: 60, maxConcurrency: 5, - report: 'junit', - reportFile: reportPath, }, { credentialsPath, fetchImpl, - stdout: () => undefined, + stdout: line => stdoutLines.push(line), stderr: () => undefined, sleep: instantSleep, }, - ); - - const xml = readFileSync(reportPath, 'utf8'); - expect(xml).toContain(' { - const { credentialsPath } = makeCreds(); - const dir = mkdtempSync(join(tmpdir(), 'junit-run-fail-')); - const reportPath = join(dir, 'results.xml'); - const fetchImpl = makeFetch((url, init) => { - if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP }; - const runId = url.split('/runs/')[1]?.split('?')[0] ?? ''; - if (runId === 'run_fresh_01') - return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') }; - if (runId === 'run_fresh_02') - return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'failed') }; - return errorBody('NOT_FOUND'); - }); - - await expect( - runTestRunAll( - { - profile: 'default', - output: 'json', - debug: false, - projectId: 'project_be', - wait: true, - timeoutSeconds: 60, - maxConcurrency: 5, - report: 'junit', - reportFile: reportPath, - }, - { - credentialsPath, - fetchImpl, - stdout: () => undefined, - stderr: () => undefined, - sleep: instantSleep, - }, - ), - ).rejects.toMatchObject({ exitCode: 1 }); - - const xml = readFileSync(reportPath, 'utf8'); - expect(xml).toContain('failures="1"'); - expect(xml).toContain('name="test_be_02"'); - }); - - it('rejects --report without --wait', async () => { - await expect( - runTestRunAll( - { - profile: 'default', - output: 'json', - debug: false, - projectId: 'project_be', - wait: false, - timeoutSeconds: 60, - maxConcurrency: 5, - report: 'junit', - reportFile: './results.xml', - }, - {}, - ), - ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); - }); - - it('rejects --report-suite-name without --report', async () => { - await expect( - runTestRunAll( - { - profile: 'default', - output: 'json', - debug: false, - projectId: 'project_be', - wait: true, - timeoutSeconds: 60, - maxConcurrency: 5, - reportSuiteName: 'orphan-suite', - }, - {}, - ), - ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); - }); - - it('--dry-run --report junit writes canned sample XML', async () => { - const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-')); - const reportPath = join(dir, 'results.xml'); - - await runTestRunAll( - { - profile: 'default', - output: 'json', - debug: false, - dryRun: true, - projectId: 'project_be', - wait: true, - timeoutSeconds: 60, - maxConcurrency: 5, - report: 'junit', - reportFile: reportPath, - }, - { - stdout: () => undefined, - stderr: () => undefined, - }, - ); - - const xml = readFileSync(reportPath, 'utf8'); - expect(xml).toContain('name="test_fresh_wave_01"'); - expect(xml).toContain('failures="1"'); - }); - - it('--dry-run --report junit --report-suite-name overrides canned suite name', async () => { - const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-suite-')); - const reportPath = join(dir, 'results.xml'); - - await runTestRunAll( - { - profile: 'default', - output: 'json', - debug: false, - dryRun: true, - projectId: 'project_be', - wait: true, - timeoutSeconds: 60, - maxConcurrency: 5, - report: 'junit', - reportFile: reportPath, - reportSuiteName: 'ci-checkout-suite', - }, - { - stdout: () => undefined, - stderr: () => undefined, - }, - ); + ).catch(e => e); - const xml = readFileSync(reportPath, 'utf8'); - expect(xml).toContain('; + }; + expect(parsed.accepted).toHaveLength(2); + expect(parsed.accepted.map(r => r.runId).sort()).toEqual(['run_fresh_01', 'run_fresh_02']); + expect(parsed.accepted.every(r => r.status === 'timeout')).toBe(true); }); }); diff --git a/src/commands/test.ts b/src/commands/test.ts index f11e688..9982651 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -5951,6 +5951,22 @@ export async function runTestRunAll( }, }; } + if (err instanceof RequestTimeoutError) { + // Client-side per-request timeout during polling — classify as timeout + // (exit 7) so the fan-out completes and stdout carries every runId. + // Without this, RequestTimeoutError rejects the fan-out before out.print(), + // leaving JSON consumers with empty stdout (mirrors create-batch --run). + return { + testId: entry.testId, + runId, + status: 'timeout', + error: { + code: 'UNSUPPORTED', + message: err.message, + exitCode: err.exitCode, + }, + }; + } if (err instanceof ApiError) { // Preserve the real exit code + envelope (AUTH_INVALID=3, NOT_FOUND=4, // RATE_LIMITED=11, …) instead of flattening every member failure to 1 @@ -7160,6 +7176,22 @@ export async function runTestRerun( }, }; } + if (err instanceof RequestTimeoutError) { + // Client-side per-request timeout during polling — classify as timeout + // (exit 7) so the fan-out completes and stdout carries every runId. + // Without this, RequestTimeoutError rejects the fan-out before out.print(), + // leaving JSON consumers with empty stdout (mirrors create-batch --run). + return { + testId: entry.testId, + runId: entry.runId, + status: 'timeout', + error: { + code: 'UNSUPPORTED', + message: err.message, + exitCode: err.exitCode, + }, + }; + } 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