Skip to content

Commit 3881b35

Browse files
feat(test): JUnit XML report export for batch --wait runs
1 parent 3ab8136 commit 3881b35

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 at `--verbose`); 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.
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';
@@ -3536,3 +3536,169 @@ describe('dashboardUrl on run completion', () => {
35363536
).toBe(true);
35373537
});
35383538
});
3539+
3540+
describe('runTestRunAll — JUnit report export', () => {
3541+
const BATCH_FRESH_RESP: BatchRunFreshResponse = {
3542+
accepted: [
3543+
{ testId: 'test_be_01', runId: 'run_fresh_01', enqueuedAt: '2026-06-09T10:00:00.000Z' },
3544+
{ testId: 'test_be_02', runId: 'run_fresh_02', enqueuedAt: '2026-06-09T10:00:01.000Z' },
3545+
],
3546+
conflicts: [],
3547+
deferred: [],
3548+
skippedFrontend: [],
3549+
skippedIntegration: [],
3550+
};
3551+
3552+
function makeTerminalRun(runId: string, testId: string, status: string): RunResponse {
3553+
return {
3554+
runId,
3555+
testId,
3556+
projectId: 'project_be',
3557+
userId: 'user_1',
3558+
status: status as RunResponse['status'],
3559+
source: 'cli',
3560+
createdAt: '2026-06-09T10:00:00.000Z',
3561+
startedAt: '2026-06-09T10:00:01.000Z',
3562+
finishedAt: '2026-06-09T10:00:30.000Z',
3563+
codeVersion: 'v1',
3564+
targetUrl: 'https://api.example.com',
3565+
createdFrom: 'cli',
3566+
failedStepIndex: null,
3567+
failureKind: null,
3568+
error: null,
3569+
videoUrl: null,
3570+
stepSummary: { total: 3, completed: 3, passedCount: status === 'passed' ? 3 : 0, failedCount: 0 },
3571+
};
3572+
}
3573+
3574+
it('--wait --report junit writes XML after polling', async () => {
3575+
const { credentialsPath } = makeCreds();
3576+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-all-'));
3577+
const reportPath = join(dir, 'results.xml');
3578+
const fetchImpl = makeFetch((url, init) => {
3579+
if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP };
3580+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
3581+
if (runId === 'run_fresh_01')
3582+
return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') };
3583+
if (runId === 'run_fresh_02')
3584+
return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'passed') };
3585+
return errorBody('NOT_FOUND');
3586+
});
3587+
3588+
await runTestRunAll(
3589+
{
3590+
profile: 'default',
3591+
output: 'json',
3592+
debug: false,
3593+
projectId: 'project_be',
3594+
wait: true,
3595+
timeoutSeconds: 60,
3596+
maxConcurrency: 5,
3597+
report: 'junit',
3598+
reportFile: reportPath,
3599+
},
3600+
{
3601+
credentialsPath,
3602+
fetchImpl,
3603+
stdout: () => undefined,
3604+
stderr: () => undefined,
3605+
sleep: instantSleep,
3606+
},
3607+
);
3608+
3609+
const xml = readFileSync(reportPath, 'utf8');
3610+
expect(xml).toContain('<testsuite name="testsprite:project_be"');
3611+
expect(xml).toContain('name="test_be_01"');
3612+
expect(xml).toContain('name="test_be_02"');
3613+
expect(xml).toContain('failures="0"');
3614+
});
3615+
3616+
it('--wait --report junit writes XML even when batch exits 1', async () => {
3617+
const { credentialsPath } = makeCreds();
3618+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-fail-'));
3619+
const reportPath = join(dir, 'results.xml');
3620+
const fetchImpl = makeFetch((url, init) => {
3621+
if ((init.method ?? 'GET') === 'POST') return { body: BATCH_FRESH_RESP };
3622+
const runId = url.split('/runs/')[1]?.split('?')[0] ?? '';
3623+
if (runId === 'run_fresh_01')
3624+
return { body: makeTerminalRun('run_fresh_01', 'test_be_01', 'passed') };
3625+
if (runId === 'run_fresh_02')
3626+
return { body: makeTerminalRun('run_fresh_02', 'test_be_02', 'failed') };
3627+
return errorBody('NOT_FOUND');
3628+
});
3629+
3630+
await expect(
3631+
runTestRunAll(
3632+
{
3633+
profile: 'default',
3634+
output: 'json',
3635+
debug: false,
3636+
projectId: 'project_be',
3637+
wait: true,
3638+
timeoutSeconds: 60,
3639+
maxConcurrency: 5,
3640+
report: 'junit',
3641+
reportFile: reportPath,
3642+
},
3643+
{
3644+
credentialsPath,
3645+
fetchImpl,
3646+
stdout: () => undefined,
3647+
stderr: () => undefined,
3648+
sleep: instantSleep,
3649+
},
3650+
),
3651+
).rejects.toMatchObject({ exitCode: 1 });
3652+
3653+
const xml = readFileSync(reportPath, 'utf8');
3654+
expect(xml).toContain('failures="1"');
3655+
expect(xml).toContain('name="test_be_02"');
3656+
});
3657+
3658+
it('rejects --report without --wait', async () => {
3659+
await expect(
3660+
runTestRunAll(
3661+
{
3662+
profile: 'default',
3663+
output: 'json',
3664+
debug: false,
3665+
projectId: 'project_be',
3666+
wait: false,
3667+
timeoutSeconds: 60,
3668+
maxConcurrency: 5,
3669+
report: 'junit',
3670+
reportFile: './results.xml',
3671+
},
3672+
{},
3673+
),
3674+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3675+
});
3676+
3677+
it('--dry-run --report junit writes canned sample XML', async () => {
3678+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-'));
3679+
const reportPath = join(dir, 'results.xml');
3680+
3681+
await runTestRunAll(
3682+
{
3683+
profile: 'default',
3684+
output: 'json',
3685+
debug: false,
3686+
dryRun: true,
3687+
projectId: 'project_be',
3688+
wait: true,
3689+
timeoutSeconds: 60,
3690+
maxConcurrency: 5,
3691+
report: 'junit',
3692+
reportFile: reportPath,
3693+
},
3694+
{
3695+
stdout: () => undefined,
3696+
stderr: () => undefined,
3697+
},
3698+
);
3699+
3700+
const xml = readFileSync(reportPath, 'utf8');
3701+
expect(xml).toContain('name="test_fresh_wave_01"');
3702+
expect(xml).toContain('failures="1"');
3703+
});
3704+
});

0 commit comments

Comments
 (0)