Skip to content

Commit 995555a

Browse files
committed
feat(cli): add "testsprite ci" one-shot run + gate + GitHub-native output
1 parent 2708a40 commit 995555a

4 files changed

Lines changed: 582 additions & 0 deletions

File tree

src/commands/ci.test.ts

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/**
2+
* Unit tests for `testsprite ci`. The heavy lifting (trigger + poll) is the
3+
* batch command's, already covered by its own suites; these tests cover the
4+
* CI-facing composition seams: payload reduction, the job-summary Markdown,
5+
* the GitHub gating, and the command wiring.
6+
*/
7+
8+
import { describe, expect, it, vi, beforeEach } from 'vitest';
9+
import { Command } from 'commander';
10+
11+
// runCi drives the real batch command; mock it so these tests exercise the
12+
// CI presentation seam (capture → summarize → emit → re-throw the gate) with a
13+
// canned payload instead of a live trigger+poll.
14+
const runTestRunAllMock = vi.hoisted(() => vi.fn());
15+
vi.mock('./test.js', () => ({ runTestRunAll: runTestRunAllMock }));
16+
17+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
18+
import { tmpdir } from 'node:os';
19+
import { join } from 'node:path';
20+
import {
21+
createCiCommand,
22+
emitGithubOutputs,
23+
renderJobSummaryMarkdown,
24+
runCi,
25+
summarizeAcceptedPayload,
26+
type CiSummary,
27+
} from './ci.js';
28+
import { CLIError } from '../lib/errors.js';
29+
30+
const PAYLOAD = JSON.stringify({
31+
accepted: [
32+
{
33+
testId: 'test_a',
34+
runId: 'run_a',
35+
status: 'passed',
36+
dashboardUrl: 'https://portal.example.com/a',
37+
},
38+
{
39+
testId: 'test_b',
40+
runId: 'run_b',
41+
status: 'failed',
42+
error: { code: 'INTERNAL', message: 'boom', exitCode: 1 },
43+
},
44+
{ testId: 'test_c', runId: 'run_c', status: 'timeout' },
45+
],
46+
conflicts: [],
47+
});
48+
49+
describe('summarizeAcceptedPayload', () => {
50+
it('reduces accepted[] rows into counts and rows', () => {
51+
const summary = summarizeAcceptedPayload(PAYLOAD);
52+
expect(summary).toMatchObject({ total: 3, passed: 1, failed: 1, timedOut: 1 });
53+
expect(summary.runs[1]).toMatchObject({ testId: 'test_b', status: 'failed', error: 'boom' });
54+
});
55+
56+
it('unparseable or non-batch output reduces to an empty summary (never throws)', () => {
57+
expect(summarizeAcceptedPayload('')).toMatchObject({ total: 0, passed: 0 });
58+
expect(summarizeAcceptedPayload('{"method":"POST"}')).toMatchObject({ total: 0 });
59+
expect(summarizeAcceptedPayload('not json')).toMatchObject({ total: 0 });
60+
});
61+
});
62+
63+
describe('renderJobSummaryMarkdown', () => {
64+
it('renders the counts headline and one table row per run', () => {
65+
const md = renderJobSummaryMarkdown(summarizeAcceptedPayload(PAYLOAD));
66+
expect(md).toContain('**1/3 passed** (1 failed, 1 timed out)');
67+
expect(md).toContain('| test_a | passed | [dashboard](https://portal.example.com/a) |');
68+
expect(md).toContain('| test_c | timeout | run_c |');
69+
});
70+
});
71+
72+
describe('emitGithubOutputs', () => {
73+
const summary: CiSummary = summarizeAcceptedPayload(PAYLOAD);
74+
75+
function makeSinks() {
76+
const stdout: string[] = [];
77+
const stderr: string[] = [];
78+
const appended: Array<{ path: string; content: string }> = [];
79+
return {
80+
stdout,
81+
stderr,
82+
appended,
83+
sinks: {
84+
stdout: (line: string) => stdout.push(line),
85+
stderr: (line: string) => stderr.push(line),
86+
appendFile: (path: string, content: string) => appended.push({ path, content }),
87+
},
88+
};
89+
}
90+
91+
it('appends the job summary and annotates only non-passed runs under Actions', () => {
92+
const { stdout, appended, sinks } = makeSinks();
93+
emitGithubOutputs(
94+
summary,
95+
{ GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: '/gh/summary.md' },
96+
sinks,
97+
);
98+
expect(appended).toHaveLength(1);
99+
expect(appended[0]!.path).toBe('/gh/summary.md');
100+
expect(appended[0]!.content).toContain('TestSprite results');
101+
const annotations = stdout.filter(line => line.startsWith('::error'));
102+
expect(annotations).toHaveLength(2);
103+
expect(annotations[0]).toContain('test_b');
104+
expect(annotations[0]).toContain('boom');
105+
expect(annotations[1]).toContain('test_c');
106+
});
107+
108+
it('emits nothing off-CI, and a broken summary file downgrades to stderr', () => {
109+
const offCi = makeSinks();
110+
emitGithubOutputs(summary, {}, offCi.sinks);
111+
expect(offCi.stdout).toHaveLength(0);
112+
expect(offCi.appended).toHaveLength(0);
113+
114+
const broken = makeSinks();
115+
emitGithubOutputs(
116+
summary,
117+
{ GITHUB_STEP_SUMMARY: '/gh/summary.md' },
118+
{
119+
...broken.sinks,
120+
appendFile: () => {
121+
throw new Error('EROFS');
122+
},
123+
},
124+
);
125+
expect(broken.stderr.join('\n')).toContain('could not append');
126+
});
127+
});
128+
129+
describe('runCi', () => {
130+
beforeEach(() => {
131+
runTestRunAllMock.mockReset();
132+
});
133+
134+
const baseOpts = {
135+
profile: 'default',
136+
output: 'text' as const,
137+
endpointUrl: undefined,
138+
debug: false,
139+
verbose: false,
140+
dryRun: false,
141+
requestTimeoutMs: undefined,
142+
projectId: 'p1',
143+
timeoutSeconds: 600,
144+
maxConcurrency: 50,
145+
};
146+
147+
it('prints the machine summary, writes --summary-file, and re-throws the gate error', async () => {
148+
// The batch command prints its JSON payload to (captured) stdout, then
149+
// throws its typed gate error for the failed run.
150+
runTestRunAllMock.mockImplementation(
151+
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
152+
deps.stdout?.(PAYLOAD);
153+
throw new CLIError('1 run failed.', 1);
154+
},
155+
);
156+
157+
const stdout: string[] = [];
158+
const written: Array<{ path: string; content: string }> = [];
159+
await expect(
160+
runCi(
161+
{ ...baseOpts, summaryFile: '/out/ci.json' },
162+
{
163+
env: {},
164+
stdout: (line: string) => stdout.push(line),
165+
stderr: () => undefined,
166+
writeFile: (path: string, content: string) => written.push({ path, content }),
167+
},
168+
),
169+
).rejects.toMatchObject({ exitCode: 1 });
170+
171+
const summary = JSON.parse(stdout[0]!) as CiSummary;
172+
expect(summary).toMatchObject({ total: 3, passed: 1, failed: 1, timedOut: 1 });
173+
expect(written).toHaveLength(1);
174+
expect(written[0]!.path).toBe('/out/ci.json');
175+
});
176+
177+
it('returns the summary and emits GitHub outputs when all pass under Actions', async () => {
178+
const allPass = JSON.stringify({
179+
accepted: [{ testId: 'test_a', runId: 'run_a', status: 'passed' }],
180+
});
181+
runTestRunAllMock.mockImplementation(
182+
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
183+
deps.stdout?.(allPass);
184+
},
185+
);
186+
187+
const stdout: string[] = [];
188+
const appended: Array<{ path: string; content: string }> = [];
189+
const summary = await runCi(baseOpts, {
190+
env: { GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: '/gh/summary.md' },
191+
stdout: (line: string) => stdout.push(line),
192+
stderr: () => undefined,
193+
appendFile: (path: string, content: string) => appended.push({ path, content }),
194+
});
195+
expect(summary).toMatchObject({ total: 1, passed: 1, failed: 0 });
196+
expect(appended).toHaveLength(1);
197+
// All passed → no ::error:: annotations.
198+
expect(stdout.filter(line => line.startsWith('::error'))).toHaveLength(0);
199+
});
200+
201+
it('uses the default fs writers for --summary-file and $GITHUB_STEP_SUMMARY', async () => {
202+
runTestRunAllMock.mockImplementation(
203+
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
204+
deps.stdout?.(JSON.stringify({ accepted: [{ testId: 'test_a', status: 'failed' }] }));
205+
},
206+
);
207+
const dir = mkdtempSync(join(tmpdir(), 'ci-test-'));
208+
const summaryFile = join(dir, 'ci.json');
209+
const stepSummary = join(dir, 'step-summary.md');
210+
const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
211+
try {
212+
// No writeFile/appendFile injected → exercises the default fs writers.
213+
await runCi(
214+
{ ...baseOpts, summaryFile },
215+
{
216+
env: { GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: stepSummary },
217+
stderr: () => undefined,
218+
},
219+
);
220+
expect(JSON.parse(readFileSync(summaryFile, 'utf8'))).toMatchObject({ failed: 1 });
221+
expect(readFileSync(stepSummary, 'utf8')).toContain('TestSprite results');
222+
} finally {
223+
writeSpy.mockRestore();
224+
rmSync(dir, { recursive: true, force: true });
225+
}
226+
});
227+
228+
it('falls back to real process stdout/stderr writers when no sinks are injected', async () => {
229+
runTestRunAllMock.mockImplementation(
230+
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
231+
deps.stdout?.(JSON.stringify({ accepted: [] }));
232+
},
233+
);
234+
const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
235+
try {
236+
// Only `env` is injected → exercises the default stdout/stderr arrows.
237+
const summary = await runCi(baseOpts, { env: {} });
238+
expect(summary).toMatchObject({ total: 0 });
239+
expect(writeSpy).toHaveBeenCalled();
240+
} finally {
241+
writeSpy.mockRestore();
242+
}
243+
});
244+
});
245+
246+
describe('createCiCommand wiring', () => {
247+
it('is named "ci" and validates --timeout / --max-concurrency bounds', async () => {
248+
const cmd = createCiCommand({ stdout: () => undefined, stderr: () => undefined });
249+
expect(cmd.name()).toBe('ci');
250+
await expect(
251+
cmd.parseAsync(['--project', 'p1', '--timeout', '0'], { from: 'user' }),
252+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
253+
const cmd2 = createCiCommand({ stdout: () => undefined, stderr: () => undefined });
254+
await expect(
255+
cmd2.parseAsync(['--project', 'p1', '--max-concurrency', '101'], { from: 'user' }),
256+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
257+
});
258+
259+
it('drives runCi end-to-end through the parsed action (defaults + flags)', async () => {
260+
runTestRunAllMock.mockReset();
261+
runTestRunAllMock.mockImplementation(
262+
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
263+
deps.stdout?.(JSON.stringify({ accepted: [] }));
264+
},
265+
);
266+
const stdout: string[] = [];
267+
const cmd = createCiCommand({
268+
env: {},
269+
stdout: (line: string) => stdout.push(line),
270+
stderr: () => undefined,
271+
});
272+
await cmd.parseAsync(['--project', 'p1', '--timeout', '120', '--max-concurrency', '5'], {
273+
from: 'user',
274+
});
275+
expect(runTestRunAllMock).toHaveBeenCalledTimes(1);
276+
const firstArg = runTestRunAllMock.mock.calls[0]![0] as {
277+
timeoutSeconds: number;
278+
maxConcurrency: number;
279+
wait: boolean;
280+
};
281+
expect(firstArg).toMatchObject({ timeoutSeconds: 120, maxConcurrency: 5, wait: true });
282+
expect(JSON.parse(stdout[0]!)).toMatchObject({ total: 0 });
283+
});
284+
285+
it('forwards the global --request-timeout (seconds) as requestTimeoutMs', async () => {
286+
runTestRunAllMock.mockReset();
287+
runTestRunAllMock.mockImplementation(
288+
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
289+
deps.stdout?.(JSON.stringify({ accepted: [] }));
290+
},
291+
);
292+
// Mount `ci` under a parent that carries the global option, mirroring index.ts.
293+
const program = new Command().option('--request-timeout <s>', 'per-request timeout (seconds)');
294+
program.addCommand(
295+
createCiCommand({ env: {}, stdout: () => undefined, stderr: () => undefined }),
296+
);
297+
await program.parseAsync(['--request-timeout', '30', 'ci', '--project', 'p1'], {
298+
from: 'user',
299+
});
300+
const firstArg = runTestRunAllMock.mock.calls[0]![0] as { requestTimeoutMs?: number };
301+
expect(firstArg.requestTimeoutMs).toBe(30000);
302+
});
303+
});

0 commit comments

Comments
 (0)