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..995b6bd 100644 --- a/src/commands/test.rerun.spec.ts +++ b/src/commands/test.rerun.spec.ts @@ -4823,7 +4823,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 +4835,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 +4845,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 +4856,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 +4871,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 }>; }; @@ -4883,6 +4880,80 @@ describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out pol }); }); +// --------------------------------------------------------------------------- +// TimeoutError on single FE rerun --wait: partial stdout + exit 7 +// --------------------------------------------------------------------------- +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 rerunResp = makeFeRerunResp(); + + 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; + fetchCallCount++; + if (url.includes('/tests/test_fe_01/runs/rerun')) { + return new Response(JSON.stringify(rerunResp), { + status: 202, + headers: { 'content-type': 'application/json' }, + }); + } + if (url.includes('/runs/')) { + const runningRun: RunResponse = { + ...makeTerminalRun(rerunResp.runId, 'passed'), + status: 'running', + finishedAt: null, + }; + 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 err = await runTestRerun( + { + testIds: ['test_fe_01'], + all: false, + wait: true, + timeoutSeconds: 0, + autoHeal: false, + autoHealExplicit: false, + skipDependencies: false, + maxConcurrency: 10, + output: 'json', + profile: 'default', + dryRun: false, + debug: false, + verbose: false, + }, + { + ...creds, + sleep: instantSleep, + fetchImpl: fetchImpl as unknown as FetchImpl, + stdout: line => stdoutLines.push(line), + stderr: () => undefined, + }, + ).catch(e => e); + + 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'); + + void fetchCallCount; + }); +}); + // --------------------------------------------------------------------------- // DEV-331 piece 1 — graceful detach during batch rerun --wait (SIG-6) // --------------------------------------------------------------------------- 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',