Skip to content

Commit b675630

Browse files
feat(test): JUnit XML report export for batch --wait runs (#96)
* feat(test): JUnit XML report export for batch --wait runs * fix(ci): prettier formatting and help snapshot alignment for report flags * fix(test): address CodeRabbit review on JUnit report export * fix(test): add projectId to CliBatchRunFreshResult for JUnit inference
1 parent 0de372c commit b675630

9 files changed

Lines changed: 1087 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **JUnit XML report export for batch `--wait` runs.** `test run --all` and batch `test rerun` (`--all` or multiple test ids) accept `--report junit --report-file <path>` to write a CI-friendly XML sidecar after polling completes. `--output json` is unchanged; the report is written even when the batch exits non-zero. `--dry-run` writes a canned sample without network calls.
10+
711
## [0.2.0] - 2026-06-29
812

913
### Added

DOCUMENTATION.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,18 @@ testsprite test run test_xxxxxxxx --target-url https://staging.example.com \
344344

345345
# Dry-run prints a canned queued response (no network, no credentials)
346346
testsprite test run test_xxxxxxxx --dry-run --output json
347+
348+
# Batch BE run with JUnit XML for CI (sidecar; --output json unchanged)
349+
testsprite test run --all --project proj_xxxxxxxx --wait \
350+
--report junit --report-file ./results.xml --output json
351+
352+
# Optional custom suite name (default: testsprite:<projectId>)
353+
testsprite test run --all --project proj_xxxxxxxx --wait \
354+
--report junit --report-file ./results.xml --report-suite-name my-ci-suite --output json
347355
```
348356

357+
Batch `--report` flags apply only to `test run --all --wait` (and batch `test rerun --wait`). `--report junit --report-file <path>` writes a JUnit XML sidecar after polling completes (atomic write); `--output json` is unchanged. Optional `--report-suite-name <name>` overrides the default `testsprite:<projectId>` suite name.
358+
349359
`--target-url` must be a publicly reachable URL — the CLI pre-flights it against local addresses (`localhost`, `127.x`, `::1`, `0.0.0.0`, `169.254.x`, RFC1918) and the backend resolves it via DNS. For testing against localhost, use the [TestSprite MCP plugin](https://www.testsprite.com/docs), which handles the local tunnel. The CLI auto-mints an idempotency key (printed to stderr under `--output json`, `--verbose`, or `--debug`); pass `--idempotency-key <uuid>` to control it explicitly.
350360

351361
#### `testsprite test rerun [test-id...]`
@@ -365,10 +375,20 @@ testsprite test rerun test_be_xxxx --skip-dependencies --output json
365375
# Rerun every test in a project (batch)
366376
testsprite test rerun --all --project proj_xxxxxxxx --wait --max-concurrency 4 --output json
367377

378+
# Batch rerun with JUnit XML for CI
379+
testsprite test rerun --all --project proj_xxxxxxxx --wait \
380+
--report junit --report-file ./results.xml --output json
381+
382+
# Optional custom suite name (default: testsprite:<projectId>)
383+
testsprite test rerun --all --project proj_xxxxxxxx --wait \
384+
--report junit --report-file ./results.xml --report-suite-name my-ci-suite --output json
385+
368386
# Several specific tests
369387
testsprite test rerun test_aaaa test_bbbb --wait --output json
370388
```
371389

390+
Batch `--report` flags apply only to batch `--wait` reruns (`--all` or multiple test ids). `--report junit --report-file <path>` writes a JUnit XML sidecar after polling completes (atomic write); `--output json` is unchanged. When `--project` is omitted, the CLI infers `projectId` from polled run rows for classname / default suite naming; if inference fails, pass `--project <id>` explicitly (required under `--dry-run`).
391+
372392
Flags:
373393

374394
- `--all` — rerun every test in the resolved project; requires `--project <id>`.
@@ -377,6 +397,7 @@ Flags:
377397
- `--skip-dependencies` — backend only: rerun just the named test without expanding the producer/teardown closure.
378398
- `--max-concurrency <n>` — with `--wait`, cap on in-flight polls during a batch rerun.
379399
- `--idempotency-key <key>` — auto-minted when omitted (the minted key is printed to stderr under `--output json`, `--verbose`, or `--debug`).
400+
- `--report junit --report-file <path>` — with batch `--wait`, write a JUnit XML sidecar after polling (atomic write). Optional `--report-suite-name <name>` overrides the default `testsprite:<projectId>` suite name. Requires `--wait`; not available on single-test reruns.
380401

381402
A batch rerun returns `accepted[]` (one `runId` per dispatched test) plus `deferred[]` for any test shed by the per-key run-rate limit; under `--wait`, a non-empty `deferred[]` exits 7 with a `nextAction` you can retry with a fresh idempotency key.
382403

src/commands/test.run.spec.ts

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* sleep injection is wired through `TestDeps.sleep` to avoid real delays.
66
*/
77

8-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
99
import { tmpdir } from 'node:os';
1010
import { join } from 'node:path';
1111
import type { Command } from 'commander';
@@ -3624,3 +3624,221 @@ describe('dashboardUrl on run completion', () => {
36243624
).toBe(true);
36253625
});
36263626
});
3627+
3628+
describe('runTestRunAll — JUnit report export', () => {
3629+
const BATCH_FRESH_RESP: BatchRunFreshResponse = {
3630+
accepted: [
3631+
{ testId: 'test_be_01', runId: 'run_fresh_01', enqueuedAt: '2026-06-09T10:00:00.000Z' },
3632+
{ testId: 'test_be_02', runId: 'run_fresh_02', enqueuedAt: '2026-06-09T10:00:01.000Z' },
3633+
],
3634+
conflicts: [],
3635+
deferred: [],
3636+
skippedFrontend: [],
3637+
skippedIntegration: [],
3638+
};
3639+
3640+
function makeTerminalRun(runId: string, testId: string, status: string): RunResponse {
3641+
return {
3642+
runId,
3643+
testId,
3644+
projectId: 'project_be',
3645+
userId: 'user_1',
3646+
status: status as RunResponse['status'],
3647+
source: 'cli',
3648+
createdAt: '2026-06-09T10:00:00.000Z',
3649+
startedAt: '2026-06-09T10:00:01.000Z',
3650+
finishedAt: '2026-06-09T10:00:30.000Z',
3651+
codeVersion: 'v1',
3652+
targetUrl: 'https://api.example.com',
3653+
createdFrom: 'cli',
3654+
failedStepIndex: null,
3655+
failureKind: null,
3656+
error: null,
3657+
videoUrl: null,
3658+
stepSummary: {
3659+
total: 3,
3660+
completed: 3,
3661+
passedCount: status === 'passed' ? 3 : 0,
3662+
failedCount: 0,
3663+
},
3664+
};
3665+
}
3666+
3667+
it('--wait --report junit writes XML after polling', async () => {
3668+
const { credentialsPath } = makeCreds();
3669+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-all-'));
3670+
const reportPath = join(dir, 'results.xml');
3671+
const fetchImpl = makeFetch((url, init) => {
3672+
if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP };
3673+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
3674+
if (runId === 'run_fresh_01')
3675+
return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') };
3676+
if (runId === 'run_fresh_02')
3677+
return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'passed') };
3678+
return errorBody('NOT_FOUND');
3679+
});
3680+
3681+
await runTestRunAll(
3682+
{
3683+
profile: 'default',
3684+
output: 'json',
3685+
debug: false,
3686+
projectId: 'project_be',
3687+
wait: true,
3688+
timeoutSeconds: 60,
3689+
maxConcurrency: 5,
3690+
report: 'junit',
3691+
reportFile: reportPath,
3692+
},
3693+
{
3694+
credentialsPath,
3695+
fetchImpl,
3696+
stdout: () => undefined,
3697+
stderr: () => undefined,
3698+
sleep: instantSleep,
3699+
},
3700+
);
3701+
3702+
const xml = readFileSync(reportPath, 'utf8');
3703+
expect(xml).toContain('<testsuite name="testsprite:project_be"');
3704+
expect(xml).toContain('name="test_be_01"');
3705+
expect(xml).toContain('name="test_be_02"');
3706+
expect(xml).toContain('failures="0"');
3707+
});
3708+
3709+
it('--wait --report junit writes XML even when batch exits 1', async () => {
3710+
const { credentialsPath } = makeCreds();
3711+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-fail-'));
3712+
const reportPath = join(dir, 'results.xml');
3713+
const fetchImpl = makeFetch((url, init) => {
3714+
if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP };
3715+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
3716+
if (runId === 'run_fresh_01')
3717+
return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') };
3718+
if (runId === 'run_fresh_02')
3719+
return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'failed') };
3720+
return errorBody('NOT_FOUND');
3721+
});
3722+
3723+
await expect(
3724+
runTestRunAll(
3725+
{
3726+
profile: 'default',
3727+
output: 'json',
3728+
debug: false,
3729+
projectId: 'project_be',
3730+
wait: true,
3731+
timeoutSeconds: 60,
3732+
maxConcurrency: 5,
3733+
report: 'junit',
3734+
reportFile: reportPath,
3735+
},
3736+
{
3737+
credentialsPath,
3738+
fetchImpl,
3739+
stdout: () => undefined,
3740+
stderr: () => undefined,
3741+
sleep: instantSleep,
3742+
},
3743+
),
3744+
).rejects.toMatchObject({ exitCode: 1 });
3745+
3746+
const xml = readFileSync(reportPath, 'utf8');
3747+
expect(xml).toContain('failures="1"');
3748+
expect(xml).toContain('name="test_be_02"');
3749+
});
3750+
3751+
it('rejects --report without --wait', async () => {
3752+
await expect(
3753+
runTestRunAll(
3754+
{
3755+
profile: 'default',
3756+
output: 'json',
3757+
debug: false,
3758+
projectId: 'project_be',
3759+
wait: false,
3760+
timeoutSeconds: 60,
3761+
maxConcurrency: 5,
3762+
report: 'junit',
3763+
reportFile: './results.xml',
3764+
},
3765+
{},
3766+
),
3767+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3768+
});
3769+
3770+
it('rejects --report-suite-name without --report', async () => {
3771+
await expect(
3772+
runTestRunAll(
3773+
{
3774+
profile: 'default',
3775+
output: 'json',
3776+
debug: false,
3777+
projectId: 'project_be',
3778+
wait: true,
3779+
timeoutSeconds: 60,
3780+
maxConcurrency: 5,
3781+
reportSuiteName: 'orphan-suite',
3782+
},
3783+
{},
3784+
),
3785+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3786+
});
3787+
3788+
it('--dry-run --report junit writes canned sample XML', async () => {
3789+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-'));
3790+
const reportPath = join(dir, 'results.xml');
3791+
3792+
await runTestRunAll(
3793+
{
3794+
profile: 'default',
3795+
output: 'json',
3796+
debug: false,
3797+
dryRun: true,
3798+
projectId: 'project_be',
3799+
wait: true,
3800+
timeoutSeconds: 60,
3801+
maxConcurrency: 5,
3802+
report: 'junit',
3803+
reportFile: reportPath,
3804+
},
3805+
{
3806+
stdout: () => undefined,
3807+
stderr: () => undefined,
3808+
},
3809+
);
3810+
3811+
const xml = readFileSync(reportPath, 'utf8');
3812+
expect(xml).toContain('name="test_fresh_wave_01"');
3813+
expect(xml).toContain('failures="1"');
3814+
});
3815+
3816+
it('--dry-run --report junit --report-suite-name overrides canned suite name', async () => {
3817+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-suite-'));
3818+
const reportPath = join(dir, 'results.xml');
3819+
3820+
await runTestRunAll(
3821+
{
3822+
profile: 'default',
3823+
output: 'json',
3824+
debug: false,
3825+
dryRun: true,
3826+
projectId: 'project_be',
3827+
wait: true,
3828+
timeoutSeconds: 60,
3829+
maxConcurrency: 5,
3830+
report: 'junit',
3831+
reportFile: reportPath,
3832+
reportSuiteName: 'ci-checkout-suite',
3833+
},
3834+
{
3835+
stdout: () => undefined,
3836+
stderr: () => undefined,
3837+
},
3838+
);
3839+
3840+
const xml = readFileSync(reportPath, 'utf8');
3841+
expect(xml).toContain('<testsuite name="ci-checkout-suite"');
3842+
expect(xml).not.toContain('testsprite:project_be');
3843+
});
3844+
});

0 commit comments

Comments
 (0)