diff --git a/src/commands/test.ts b/src/commands/test.ts index 8bf5ce5..32d1db0 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -4985,7 +4985,10 @@ async function runBatchRun( return { ...r, dashboardUrl: state.dashboardUrl }; }) : batchRunResults; - out.print({ results: enrichedResults }); + out.print({ + results: enrichedResults, + creation: { results: createResponse.results, summary: createResponse.summary }, + }); } else if (!opts.wait) { // Text mode, no --wait: statuses are non-terminal by design ('queued'), // so a pass/fail summary would misread a successful dispatch as @@ -10546,7 +10549,12 @@ export async function runTestRunAll( }); // Rate-deferred tests were NOT dispatched → signal incomplete (exit 7), // mirroring `test rerun --all`. The user retries with a fresh invocation. - if (deferred.length > 0) { + // Under --wait, defer the gate when at least one run was accepted so the + // accepted runs get polled first — a higher-priority auth/client error + // (issue #471) must be able to surface instead of being masked by the + // deferred (exit 7) throw. The gate still fires afterwards for a + // deferred-only batch. + if (deferred.length > 0 && !(opts.wait && accepted.length > 0)) { throw new CLIError( `Batch run incomplete: ${deferred.length} test${deferred.length !== 1 ? 's' : ''} rate-deferred (per-key run budget). Retry these individually after ~60s: ${deferred.map(d => d.testId).join(' ')}`, 7, @@ -11304,8 +11312,14 @@ export async function runTestRunAll( // Rate-deferred tests were never dispatched → the batch is incomplete (exit 7), // mirroring `test rerun --all`. Checked before the failed-run throw so the - // operator learns to retry the deferred set. - if (deferred.length > 0) { + // operator learns to retry the deferred set — EXCEPT when an accepted run + // already failed with a higher-priority auth/client error, which must win + // (issue #471): a bad credential is actionable before a retry-the-deferred + // hint, and the deferred gate must not mask it. + const hasAuthFailure = freshRunResults.some( + r => r.status === 'error' && r.error !== undefined && isAuthCode(r.error.code), + ); + if (deferred.length > 0 && !hasAuthFailure) { throw new CLIError( `Batch run incomplete: ${deferred.length} test${deferred.length !== 1 ? 's' : ''} rate-deferred (per-key run budget) — retry these individually after ~60s: ${deferred.map(d => d.testId).join(' ')}`, 7, @@ -11319,6 +11333,15 @@ export async function runTestRunAll( const failure = resolveWaitFailure(freshRunResults, { timeoutSeconds: opts.timeoutSeconds }); if (failure) throw failure; + // If no higher-priority error surfaced but the batch is still incomplete + // because of rate-deferred tests, keep the deferred (exit 7) behaviour. + if (deferred.length > 0) { + throw new CLIError( + `Batch run incomplete: ${deferred.length} test${deferred.length !== 1 ? 's' : ''} rate-deferred (per-key run budget) — retry these individually after ~60s: ${deferred.map(d => d.testId).join(' ')}`, + 7, + ); + } + // Nothing of ours dispatched (the poll set was auto-resumed in-flight runs // only) and every remaining conflict is a credits refusal → exit 12, the same // shape the single-run route answers. diff --git a/src/lib/interrupt.ts b/src/lib/interrupt.ts index 939a2fd..f291d32 100644 --- a/src/lib/interrupt.ts +++ b/src/lib/interrupt.ts @@ -165,7 +165,7 @@ export class ShutdownController { } class BrokenPipeInterruptError extends InterruptError { - override readonly exitCode = 0; + override readonly exitCode = (process.exitCode as number | undefined) ?? 0; constructor() { // Existing graceful-detach consumers read `.signal` to render their @@ -366,7 +366,7 @@ export function installBrokenPipeGuard(deps: BrokenPipeDeps = {}): void { shutdown.brokenPipe(); return; } - exit(0); + exit((process.exitCode as number | undefined) ?? 0); return; } throw error;