Skip to content

Commit 65f21ad

Browse files
feat(test): JUnit XML report export for batch --wait runs
1 parent e53257d commit 65f21ad

9 files changed

Lines changed: 889 additions & 4 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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,10 @@ 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
347351
```
348352

349353
`--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.
@@ -365,6 +369,10 @@ testsprite test rerun test_be_xxxx --skip-dependencies --output json
365369
# Rerun every test in a project (batch)
366370
testsprite test rerun --all --project proj_xxxxxxxx --wait --max-concurrency 4 --output json
367371

372+
# Batch rerun with JUnit XML for CI
373+
testsprite test rerun --all --project proj_xxxxxxxx --wait \
374+
--report junit --report-file ./results.xml --output json
375+
368376
# Several specific tests
369377
testsprite test rerun test_aaaa test_bbbb --wait --output json
370378
```
@@ -377,6 +385,7 @@ Flags:
377385
- `--skip-dependencies` — backend only: rerun just the named test without expanding the producer/teardown closure.
378386
- `--max-concurrency <n>` — with `--wait`, cap on in-flight polls during a batch rerun.
379387
- `--idempotency-key <key>` — auto-minted when omitted (the minted key is printed to stderr under `--output json`, `--verbose`, or `--debug`).
388+
- `--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.
380389

381390
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.
382391

src/commands/test.run.spec.ts

Lines changed: 167 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,169 @@ 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: { total: 3, completed: 3, passedCount: status === 'passed' ? 3 : 0, failedCount: 0 },
3659+
};
3660+
}
3661+
3662+
it('--wait --report junit writes XML after polling', async () => {
3663+
const { credentialsPath } = makeCreds();
3664+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-all-'));
3665+
const reportPath = join(dir, 'results.xml');
3666+
const fetchImpl = makeFetch((url, init) => {
3667+
if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP };
3668+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
3669+
if (runId === 'run_fresh_01')
3670+
return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') };
3671+
if (runId === 'run_fresh_02')
3672+
return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'passed') };
3673+
return errorBody('NOT_FOUND');
3674+
});
3675+
3676+
await runTestRunAll(
3677+
{
3678+
profile: 'default',
3679+
output: 'json',
3680+
debug: false,
3681+
projectId: 'project_be',
3682+
wait: true,
3683+
timeoutSeconds: 60,
3684+
maxConcurrency: 5,
3685+
report: 'junit',
3686+
reportFile: reportPath,
3687+
},
3688+
{
3689+
credentialsPath,
3690+
fetchImpl,
3691+
stdout: () => undefined,
3692+
stderr: () => undefined,
3693+
sleep: instantSleep,
3694+
},
3695+
);
3696+
3697+
const xml = readFileSync(reportPath, 'utf8');
3698+
expect(xml).toContain('<testsuite name="testsprite:project_be"');
3699+
expect(xml).toContain('name="test_be_01"');
3700+
expect(xml).toContain('name="test_be_02"');
3701+
expect(xml).toContain('failures="0"');
3702+
});
3703+
3704+
it('--wait --report junit writes XML even when batch exits 1', async () => {
3705+
const { credentialsPath } = makeCreds();
3706+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-fail-'));
3707+
const reportPath = join(dir, 'results.xml');
3708+
const fetchImpl = makeFetch((url, init) => {
3709+
if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP };
3710+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
3711+
if (runId === 'run_fresh_01')
3712+
return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') };
3713+
if (runId === 'run_fresh_02')
3714+
return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'failed') };
3715+
return errorBody('NOT_FOUND');
3716+
});
3717+
3718+
await expect(
3719+
runTestRunAll(
3720+
{
3721+
profile: 'default',
3722+
output: 'json',
3723+
debug: false,
3724+
projectId: 'project_be',
3725+
wait: true,
3726+
timeoutSeconds: 60,
3727+
maxConcurrency: 5,
3728+
report: 'junit',
3729+
reportFile: reportPath,
3730+
},
3731+
{
3732+
credentialsPath,
3733+
fetchImpl,
3734+
stdout: () => undefined,
3735+
stderr: () => undefined,
3736+
sleep: instantSleep,
3737+
},
3738+
),
3739+
).rejects.toMatchObject({ exitCode: 1 });
3740+
3741+
const xml = readFileSync(reportPath, 'utf8');
3742+
expect(xml).toContain('failures="1"');
3743+
expect(xml).toContain('name="test_be_02"');
3744+
});
3745+
3746+
it('rejects --report without --wait', async () => {
3747+
await expect(
3748+
runTestRunAll(
3749+
{
3750+
profile: 'default',
3751+
output: 'json',
3752+
debug: false,
3753+
projectId: 'project_be',
3754+
wait: false,
3755+
timeoutSeconds: 60,
3756+
maxConcurrency: 5,
3757+
report: 'junit',
3758+
reportFile: './results.xml',
3759+
},
3760+
{},
3761+
),
3762+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3763+
});
3764+
3765+
it('--dry-run --report junit writes canned sample XML', async () => {
3766+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-'));
3767+
const reportPath = join(dir, 'results.xml');
3768+
3769+
await runTestRunAll(
3770+
{
3771+
profile: 'default',
3772+
output: 'json',
3773+
debug: false,
3774+
dryRun: true,
3775+
projectId: 'project_be',
3776+
wait: true,
3777+
timeoutSeconds: 60,
3778+
maxConcurrency: 5,
3779+
report: 'junit',
3780+
reportFile: reportPath,
3781+
},
3782+
{
3783+
stdout: () => undefined,
3784+
stderr: () => undefined,
3785+
},
3786+
);
3787+
3788+
const xml = readFileSync(reportPath, 'utf8');
3789+
expect(xml).toContain('name="test_fresh_wave_01"');
3790+
expect(xml).toContain('failures="1"');
3791+
});
3792+
});

0 commit comments

Comments
 (0)