Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 303 additions & 0 deletions src/commands/ci.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
/**
* Unit tests for `testsprite ci`. The heavy lifting (trigger + poll) is the
* batch command's, already covered by its own suites; these tests cover the
* CI-facing composition seams: payload reduction, the job-summary Markdown,
* the GitHub gating, and the command wiring.
*/

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { Command } from 'commander';

// runCi drives the real batch command; mock it so these tests exercise the
// CI presentation seam (capture → summarize → emit → re-throw the gate) with a
// canned payload instead of a live trigger+poll.
const runTestRunAllMock = vi.hoisted(() => vi.fn());
vi.mock('./test.js', () => ({ runTestRunAll: runTestRunAllMock }));

import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createCiCommand,
emitGithubOutputs,
renderJobSummaryMarkdown,
runCi,
summarizeAcceptedPayload,
type CiSummary,
} from './ci.js';
import { CLIError } from '../lib/errors.js';

const PAYLOAD = JSON.stringify({
accepted: [
{
testId: 'test_a',
runId: 'run_a',
status: 'passed',
dashboardUrl: 'https://portal.example.com/a',
},
{
testId: 'test_b',
runId: 'run_b',
status: 'failed',
error: { code: 'INTERNAL', message: 'boom', exitCode: 1 },
},
{ testId: 'test_c', runId: 'run_c', status: 'timeout' },
],
conflicts: [],
});

describe('summarizeAcceptedPayload', () => {
it('reduces accepted[] rows into counts and rows', () => {
const summary = summarizeAcceptedPayload(PAYLOAD);
expect(summary).toMatchObject({ total: 3, passed: 1, failed: 1, timedOut: 1 });
expect(summary.runs[1]).toMatchObject({ testId: 'test_b', status: 'failed', error: 'boom' });
});

it('unparseable or non-batch output reduces to an empty summary (never throws)', () => {
expect(summarizeAcceptedPayload('')).toMatchObject({ total: 0, passed: 0 });
expect(summarizeAcceptedPayload('{"method":"POST"}')).toMatchObject({ total: 0 });
expect(summarizeAcceptedPayload('not json')).toMatchObject({ total: 0 });
});
});

describe('renderJobSummaryMarkdown', () => {
it('renders the counts headline and one table row per run', () => {
const md = renderJobSummaryMarkdown(summarizeAcceptedPayload(PAYLOAD));
expect(md).toContain('**1/3 passed** (1 failed, 1 timed out)');
expect(md).toContain('| test_a | passed | [dashboard](https://portal.example.com/a) |');
expect(md).toContain('| test_c | timeout | run_c |');
});
});

describe('emitGithubOutputs', () => {
const summary: CiSummary = summarizeAcceptedPayload(PAYLOAD);

function makeSinks() {
const stdout: string[] = [];
const stderr: string[] = [];
const appended: Array<{ path: string; content: string }> = [];
return {
stdout,
stderr,
appended,
sinks: {
stdout: (line: string) => stdout.push(line),
stderr: (line: string) => stderr.push(line),
appendFile: (path: string, content: string) => appended.push({ path, content }),
},
};
}

it('appends the job summary and annotates only non-passed runs under Actions', () => {
const { stdout, appended, sinks } = makeSinks();
emitGithubOutputs(
summary,
{ GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: '/gh/summary.md' },
sinks,
);
expect(appended).toHaveLength(1);
expect(appended[0]!.path).toBe('/gh/summary.md');
expect(appended[0]!.content).toContain('TestSprite results');
const annotations = stdout.filter(line => line.startsWith('::error'));
expect(annotations).toHaveLength(2);
expect(annotations[0]).toContain('test_b');
expect(annotations[0]).toContain('boom');
expect(annotations[1]).toContain('test_c');
});

it('emits nothing off-CI, and a broken summary file downgrades to stderr', () => {
const offCi = makeSinks();
emitGithubOutputs(summary, {}, offCi.sinks);
expect(offCi.stdout).toHaveLength(0);
expect(offCi.appended).toHaveLength(0);

const broken = makeSinks();
emitGithubOutputs(
summary,
{ GITHUB_STEP_SUMMARY: '/gh/summary.md' },
{
...broken.sinks,
appendFile: () => {
throw new Error('EROFS');
},
},
);
expect(broken.stderr.join('\n')).toContain('could not append');
});
});

describe('runCi', () => {
beforeEach(() => {
runTestRunAllMock.mockReset();
});

const baseOpts = {
profile: 'default',
output: 'text' as const,
endpointUrl: undefined,
debug: false,
verbose: false,
dryRun: false,
requestTimeoutMs: undefined,
projectId: 'p1',
timeoutSeconds: 600,
maxConcurrency: 50,
};

it('prints the machine summary, writes --summary-file, and re-throws the gate error', async () => {
// The batch command prints its JSON payload to (captured) stdout, then
// throws its typed gate error for the failed run.
runTestRunAllMock.mockImplementation(
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
deps.stdout?.(PAYLOAD);
throw new CLIError('1 run failed.', 1);
},
);

const stdout: string[] = [];
const written: Array<{ path: string; content: string }> = [];
await expect(
runCi(
{ ...baseOpts, summaryFile: '/out/ci.json' },
{
env: {},
stdout: (line: string) => stdout.push(line),
stderr: () => undefined,
writeFile: (path: string, content: string) => written.push({ path, content }),
},
),
).rejects.toMatchObject({ exitCode: 1 });

const summary = JSON.parse(stdout[0]!) as CiSummary;
expect(summary).toMatchObject({ total: 3, passed: 1, failed: 1, timedOut: 1 });
expect(written).toHaveLength(1);
expect(written[0]!.path).toBe('/out/ci.json');
});

it('returns the summary and emits GitHub outputs when all pass under Actions', async () => {
const allPass = JSON.stringify({
accepted: [{ testId: 'test_a', runId: 'run_a', status: 'passed' }],
});
runTestRunAllMock.mockImplementation(
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
deps.stdout?.(allPass);
},
);

const stdout: string[] = [];
const appended: Array<{ path: string; content: string }> = [];
const summary = await runCi(baseOpts, {
env: { GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: '/gh/summary.md' },
stdout: (line: string) => stdout.push(line),
stderr: () => undefined,
appendFile: (path: string, content: string) => appended.push({ path, content }),
});
expect(summary).toMatchObject({ total: 1, passed: 1, failed: 0 });
expect(appended).toHaveLength(1);
// All passed → no ::error:: annotations.
expect(stdout.filter(line => line.startsWith('::error'))).toHaveLength(0);
});

it('uses the default fs writers for --summary-file and $GITHUB_STEP_SUMMARY', async () => {
runTestRunAllMock.mockImplementation(
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
deps.stdout?.(JSON.stringify({ accepted: [{ testId: 'test_a', status: 'failed' }] }));
},
);
const dir = mkdtempSync(join(tmpdir(), 'ci-test-'));
const summaryFile = join(dir, 'ci.json');
const stepSummary = join(dir, 'step-summary.md');
const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
try {
// No writeFile/appendFile injected → exercises the default fs writers.
await runCi(
{ ...baseOpts, summaryFile },
{
env: { GITHUB_ACTIONS: 'true', GITHUB_STEP_SUMMARY: stepSummary },
stderr: () => undefined,
},
);
expect(JSON.parse(readFileSync(summaryFile, 'utf8'))).toMatchObject({ failed: 1 });
expect(readFileSync(stepSummary, 'utf8')).toContain('TestSprite results');
} finally {
writeSpy.mockRestore();
rmSync(dir, { recursive: true, force: true });
}
});

it('falls back to real process stdout/stderr writers when no sinks are injected', async () => {
runTestRunAllMock.mockImplementation(
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
deps.stdout?.(JSON.stringify({ accepted: [] }));
},
);
const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
try {
// Only `env` is injected → exercises the default stdout/stderr arrows.
const summary = await runCi(baseOpts, { env: {} });
expect(summary).toMatchObject({ total: 0 });
expect(writeSpy).toHaveBeenCalled();
} finally {
writeSpy.mockRestore();
}
});
});

describe('createCiCommand wiring', () => {
it('is named "ci" and validates --timeout / --max-concurrency bounds', async () => {
const cmd = createCiCommand({ stdout: () => undefined, stderr: () => undefined });
expect(cmd.name()).toBe('ci');
await expect(
cmd.parseAsync(['--project', 'p1', '--timeout', '0'], { from: 'user' }),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
const cmd2 = createCiCommand({ stdout: () => undefined, stderr: () => undefined });
await expect(
cmd2.parseAsync(['--project', 'p1', '--max-concurrency', '101'], { from: 'user' }),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
});

it('drives runCi end-to-end through the parsed action (defaults + flags)', async () => {
runTestRunAllMock.mockReset();
runTestRunAllMock.mockImplementation(
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
deps.stdout?.(JSON.stringify({ accepted: [] }));
},
);
const stdout: string[] = [];
const cmd = createCiCommand({
env: {},
stdout: (line: string) => stdout.push(line),
stderr: () => undefined,
});
await cmd.parseAsync(['--project', 'p1', '--timeout', '120', '--max-concurrency', '5'], {
from: 'user',
});
expect(runTestRunAllMock).toHaveBeenCalledTimes(1);
const firstArg = runTestRunAllMock.mock.calls[0]![0] as {
timeoutSeconds: number;
maxConcurrency: number;
wait: boolean;
};
expect(firstArg).toMatchObject({ timeoutSeconds: 120, maxConcurrency: 5, wait: true });
expect(JSON.parse(stdout[0]!)).toMatchObject({ total: 0 });
});

it('forwards the global --request-timeout (seconds) as requestTimeoutMs', async () => {
runTestRunAllMock.mockReset();
runTestRunAllMock.mockImplementation(
async (_opts: unknown, deps: { stdout?: (line: string) => void }) => {
deps.stdout?.(JSON.stringify({ accepted: [] }));
},
);
// Mount `ci` under a parent that carries the global option, mirroring index.ts.
const program = new Command().option('--request-timeout <s>', 'per-request timeout (seconds)');
program.addCommand(
createCiCommand({ env: {}, stdout: () => undefined, stderr: () => undefined }),
);
await program.parseAsync(['--request-timeout', '30', 'ci', '--project', 'p1'], {
from: 'user',
});
const firstArg = runTestRunAllMock.mock.calls[0]![0] as { requestTimeoutMs?: number };
expect(firstArg.requestTimeoutMs).toBe(30000);
});
});
Loading
Loading