diff --git a/src/commands/testlist.spec.ts b/src/commands/testlist.spec.ts index 35d3a39b..0c0abaf6 100644 --- a/src/commands/testlist.spec.ts +++ b/src/commands/testlist.spec.ts @@ -617,6 +617,55 @@ describe('testlist run', () => { expect(res.accepted).toHaveLength(1); }); + it('--wait: a credits refusal beside a passing run is not a pass — exit 6', async () => { + const { env } = makeCreds(); + const stderr: string[] = []; + const fetchImpl = makeFetch((_url, init) => + (init.method ?? 'GET') === 'POST' + ? { + body: { + ...RUN_ACCEPTED, + conflicts: [{ testId: 'case-b', reason: 'insufficient_credits' }], + }, + } + : { body: makeRun('passed') }, + ); + const err = (await runTestlistRun( + { ...runBase, listId: 'list-1', wait: true }, + { env, fetchImpl, stdout: () => {}, stderr: l => stderr.push(l), sleep: instantSleep }, + ).catch(e => e)) as CLIError; + expect(err).toBeInstanceOf(CLIError); + expect(err.exitCode).toBe(6); + expect(err.message).toContain('1 case(s) not run'); + expect(stderr.join('\n')).toContain( + '1/1 passed, 0 failed/blocked, 0 timed out, 1 not run (conflict)', + ); + }); + + it('--wait: an in-flight case beside a passing run is not a pass — exit 6, names the run to follow', async () => { + const { env } = makeCreds(); + const polled: string[] = []; + const fetchImpl = makeFetch((url, init) => { + if ((init.method ?? 'GET') === 'POST') + return { + body: { + ...RUN_ACCEPTED, + conflicts: [{ testId: 'case-b', currentRunId: 'run_b_inflight' }], + }, + }; + polled.push(url); + return { body: makeRun('passed') }; + }); + const err = (await runTestlistRun( + { ...runBase, listId: 'list-1', wait: true }, + { env, fetchImpl, stdout: () => {}, stderr: () => {}, sleep: instantSleep }, + ).catch(e => e)) as CLIError; + expect(err.exitCode).toBe(6); + expect(err.message).toContain('testsprite test wait run_b_inflight'); + // Only this invocation's own run is polled; another run's verdict is never adopted. + expect(polled.every(u => !u.includes('run_b_inflight'))).toBe(true); + }); + it('--wait with a failed run → exit 1', async () => { const { env } = makeCreds(); const fetchImpl = makeFetch((_url, init) => { diff --git a/src/commands/testlist.ts b/src/commands/testlist.ts index 2a17c370..5658e6ab 100644 --- a/src/commands/testlist.ts +++ b/src/commands/testlist.ts @@ -958,7 +958,8 @@ async function runTestlistRunCore( const failed = results.filter(r => r.status !== 'passed' && r.status !== 'timeout').length; const timedOut = results.filter(r => r.status === 'timeout').length; stderrFn( - `Test-list run complete: ${passed}/${resp.accepted.length} passed, ${failed} failed/blocked, ${timedOut} timed out`, + `Test-list run complete: ${passed}/${resp.accepted.length} passed, ${failed} failed/blocked, ${timedOut} timed out` + + (resp.conflicts.length > 0 ? `, ${resp.conflicts.length} not run (conflict)` : ''), ); const jsonPayload = { @@ -1042,6 +1043,24 @@ async function runTestlistRunCore( if (failure) throw failure; assertNoNotFound(resp); + + // A mixed batch (some cases dispatched, some refused or already in flight) + // must not pass on the dispatched runs alone: the conflicted cases were never + // run by this invocation. Exit 6, as `test run --all --wait` does for a hard + // conflict beside accepted runs (the all-conflict branch above only fires + // when nothing was accepted). + if (resp.conflicts.length > 0) { + const pollIds = resp.conflicts + .map(c => c.currentRunId) + .filter((id): id is string => Boolean(id)); + throw new CLIError( + `${resp.conflicts.length} case(s) not run: ${summarizeConflicts(resp.conflicts)}` + + (pollIds.length > 0 + ? `. Poll in-flight runs: testsprite test wait ${pollIds.join(' ')}` + : ''), + 6, + ); + } return resp; }