diff --git a/package-lock.json b/package-lock.json index 4461016..f9afe03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@testsprite/testsprite-cli", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@testsprite/testsprite-cli", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "commander": "^12.1.0", diff --git a/src/commands/test.rerun.spec.ts b/src/commands/test.rerun.spec.ts index 3797f14..c4e865a 100644 --- a/src/commands/test.rerun.spec.ts +++ b/src/commands/test.rerun.spec.ts @@ -9,8 +9,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { ApiError, InterruptError, RequestTimeoutError } from '../lib/errors.js'; -import { ShutdownController } from '../lib/interrupt.js'; +import { ApiError, RequestTimeoutError } from '../lib/errors.js'; import type { RunResponse, RerunResponse, BatchRerunResponse } from '../lib/runs.types.js'; import type { FetchImpl } from '../lib/http.js'; import { runTestRerun, resolveWaitRequestTimeoutMs } from './test.js'; @@ -4823,7 +4822,6 @@ describe('rerun --wait — dashboardUrl on terminal output', () => { // --------------------------------------------------------------------------- // Batch --all --wait fan-out: RequestTimeoutError must not leave stdout empty // --------------------------------------------------------------------------- - 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(); @@ -4836,6 +4834,7 @@ describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out pol conflicts: [], closure: { byProject: [] }, }; + const fetchImpl = makeFetch(url => { if (url.includes('/tests/batch/rerun')) { return { status: 202, body: batchResp }; @@ -4845,8 +4844,8 @@ describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out pol } return errorBody('NOT_FOUND'); }); - const stdoutLines: string[] = []; + const stdoutLines: string[] = []; const err = await runTestRerun( { testIds: ['test_1', 'test_2'], @@ -4856,12 +4855,10 @@ describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out pol autoHeal: false, autoHealExplicit: false, skipDependencies: false, - maxConcurrency: 10, - output: 'json', + maxConcurrency: 1, profile: 'default', - dryRun: false, + output: 'json', debug: false, - verbose: false, }, { ...creds, @@ -4873,7 +4870,6 @@ describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out pol ).catch(e => e); 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 }>; }; @@ -4884,59 +4880,50 @@ describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out pol }); // --------------------------------------------------------------------------- -// DEV-331 piece 1 — graceful detach during batch rerun --wait (SIG-6) +// TimeoutError on single FE rerun --wait: partial stdout + exit 7 // --------------------------------------------------------------------------- - -describe('R-BAT: batch rerun --wait — InterruptError partial lists all dispatched runIds (DEV-331)', () => { - it('interrupt mid fan-out → stdout partial covers every accepted runId, honest stderr, exit 130', async () => { +describe('[finding-4] single FE rerun --wait: TimeoutError writes partial JSON to stdout', () => { + it('exit 7 AND stdout contains {runId, status:"running"} when --timeout polling deadline is exceeded', async () => { const creds = makeCreds(); - const shutdown = new ShutdownController(); - const batchResp: BatchRerunResponse = { - accepted: [ - { 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 rerunResp = makeFeRerunResp(); - // Batch trigger resolves; every run poll hangs until the composed signal aborts. - const fetchImpl: FetchImpl = (async (input: unknown, init: RequestInit = {}) => { + let fetchCallCount = 0; + const fetchImpl: typeof globalThis.fetch = async (input, _init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as { url: string }).url; - if (url.includes('/tests/batch/rerun')) { - return new Response(JSON.stringify(batchResp), { + fetchCallCount++; + if (url.includes('/tests/test_fe_01/runs/rerun')) { + return new Response(JSON.stringify(rerunResp), { status: 202, headers: { 'content-type': 'application/json' }, }); } - return new Promise((_resolve, reject) => { - const signal = init.signal; - const rejectWithReason = (): void => { - const reason: unknown = signal?.reason; - reject(reason instanceof Error ? reason : new Error('aborted')); + if (url.includes('/runs/')) { + const runningRun: RunResponse = { + ...makeTerminalRun(rerunResp.runId, 'passed'), + status: 'running', + finishedAt: null, }; - if (signal?.aborted) { - rejectWithReason(); - return; - } - signal?.addEventListener('abort', rejectWithReason, { once: true }); - }); - }) as FetchImpl; + return new Response(JSON.stringify(runningRun), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ error: { code: 'NOT_FOUND' } }), { status: 404 }); + }; const stdoutLines: string[] = []; - const stderrLines: string[] = []; - const pending = runTestRerun( + + const err = await runTestRerun( { - testIds: ['test_1', 'test_2'], + testIds: ['test_fe_01'], all: false, wait: true, - timeoutSeconds: 600, + timeoutSeconds: 0, autoHeal: false, autoHealExplicit: false, skipDependencies: false, @@ -4950,30 +4937,18 @@ describe('R-BAT: batch rerun --wait — InterruptError partial lists all dispatc { ...creds, sleep: instantSleep, - fetchImpl, + fetchImpl: fetchImpl as unknown as FetchImpl, stdout: line => stdoutLines.push(line), - stderr: line => stderrLines.push(line), - shutdown, + stderr: () => undefined, }, - ); - setTimeout(() => shutdown.interrupt('SIGINT'), 10); - - const err = await pending.catch(e => e); - expect(err).toBeInstanceOf(InterruptError); - expect((err as InterruptError).exitCode).toBe(130); + ).catch(e => e); - // SIG-6: the partial lists ALL dispatched runIds, marked running. - const stdoutJson = JSON.parse(stdoutLines.join('\n')) as { - accepted: Array<{ runId: string; status: string }>; - }; - const byRunId = new Map(stdoutJson.accepted.map(r => [r.runId, r.status])); - expect(byRunId.get('run_b1')).toBe('running'); - expect(byRunId.get('run_b2')).toBe('running'); + expect(err).toMatchObject({ exitCode: 7 }); + expect(stdoutLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(stdoutLines.join('\n')) as { runId: string; status: string }; + expect(parsed.runId).toBe(rerunResp.runId); + expect(parsed.status).toBe('running'); - const stderrBlock = stderrLines.join('\n'); - expect(stderrBlock).toContain('Interrupted (SIGINT)'); - expect(stderrBlock).toContain('billing'); - expect(stderrBlock).toContain('run_b1'); - expect(stderrBlock).toContain('run_b2'); + void fetchCallCount; }); }); diff --git a/src/commands/test.ts b/src/commands/test.ts index 7839ca7..3949ee4 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -7560,6 +7560,18 @@ export async function runTestRerun( } catch (err) { if (err instanceof TimeoutError) { ticker.finalize(`Run ${rerunResp.runId} — timed out after ${opts.timeoutSeconds}s`); + // Mirror the RequestTimeoutError path: emit a partial run to stdout so + // JSON consumers and AI agents can grab the runId and chain into + // `testsprite test wait ` without parsing the stderr error envelope. + const timeoutPartial = { runId: rerunResp.runId, status: 'running' as const }; + out.print(timeoutPartial, data => { + const p = data as typeof timeoutPartial; + return [ + `runId ${p.runId}`, + `status ${p.status} (timed out after ${opts.timeoutSeconds}s)`, + `hint Re-attach with: testsprite test wait ${p.runId}`, + ].join('\n'); + }); throw ApiError.fromEnvelope({ error: { code: 'UNSUPPORTED',