Skip to content

Commit f5700dd

Browse files
fix(test): address CodeRabbit review on JUnit report export
1 parent 45db6a8 commit f5700dd

7 files changed

Lines changed: 158 additions & 15 deletions

File tree

DOCUMENTATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,14 @@ testsprite test run test_xxxxxxxx --dry-run --output json
348348
# Batch BE run with JUnit XML for CI (sidecar; --output json unchanged)
349349
testsprite test run --all --project proj_xxxxxxxx --wait \
350350
--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
351355
```
352356

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+
353359
`--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.
354360

355361
#### `testsprite test rerun [test-id...]`
@@ -373,10 +379,16 @@ testsprite test rerun --all --project proj_xxxxxxxx --wait --max-concurrency 4 -
373379
testsprite test rerun --all --project proj_xxxxxxxx --wait \
374380
--report junit --report-file ./results.xml --output json
375381

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+
376386
# Several specific tests
377387
testsprite test rerun test_aaaa test_bbbb --wait --output json
378388
```
379389

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+
380392
Flags:
381393

382394
- `--all` — rerun every test in the resolved project; requires `--project <id>`.

src/commands/test.run.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3679,6 +3679,24 @@ describe('runTestRunAll — JUnit report export', () => {
36793679
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
36803680
});
36813681

3682+
it('rejects --report-suite-name without --report', async () => {
3683+
await expect(
3684+
runTestRunAll(
3685+
{
3686+
profile: 'default',
3687+
output: 'json',
3688+
debug: false,
3689+
projectId: 'project_be',
3690+
wait: true,
3691+
timeoutSeconds: 60,
3692+
maxConcurrency: 5,
3693+
reportSuiteName: 'orphan-suite',
3694+
},
3695+
{},
3696+
),
3697+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3698+
});
3699+
36823700
it('--dry-run --report junit writes canned sample XML', async () => {
36833701
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-'));
36843702
const reportPath = join(dir, 'results.xml');
@@ -3706,4 +3724,33 @@ describe('runTestRunAll — JUnit report export', () => {
37063724
expect(xml).toContain('name="test_fresh_wave_01"');
37073725
expect(xml).toContain('failures="1"');
37083726
});
3727+
3728+
it('--dry-run --report junit --report-suite-name overrides canned suite name', async () => {
3729+
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-suite-'));
3730+
const reportPath = join(dir, 'results.xml');
3731+
3732+
await runTestRunAll(
3733+
{
3734+
profile: 'default',
3735+
output: 'json',
3736+
debug: false,
3737+
dryRun: true,
3738+
projectId: 'project_be',
3739+
wait: true,
3740+
timeoutSeconds: 60,
3741+
maxConcurrency: 5,
3742+
report: 'junit',
3743+
reportFile: reportPath,
3744+
reportSuiteName: 'ci-checkout-suite',
3745+
},
3746+
{
3747+
stdout: () => undefined,
3748+
stderr: () => undefined,
3749+
},
3750+
);
3751+
3752+
const xml = readFileSync(reportPath, 'utf8');
3753+
expect(xml).toContain('<testsuite name="ci-checkout-suite"');
3754+
expect(xml).not.toContain('testsprite:project_be');
3755+
});
37093756
});

src/commands/test.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { findSample, sampleJUnitReportXml } from '../lib/dry-run/samples.js';
2121
import {
2222
assertJUnitReportOptions,
2323
buildJUnitReport,
24+
resolveBatchReportProjectId,
2425
writeJUnitReportFile,
2526
type JUnitReportFormat,
2627
parseJUnitReportFormat,
@@ -5083,15 +5084,16 @@ async function writeBatchJUnitReportIfRequested(
50835084
report?: JUnitReportFormat;
50845085
reportFile?: string;
50855086
reportSuiteName?: string;
5086-
projectId: string;
5087+
projectId?: string;
50875088
},
50885089
results: readonly JUnitTestResult[],
50895090
): Promise<void> {
50905091
if (opts.report !== 'junit' || opts.reportFile === undefined) return;
5091-
const suiteName = opts.reportSuiteName ?? `testsprite:${opts.projectId}`;
5092+
const projectId = resolveBatchReportProjectId(opts, results);
5093+
const suiteName = opts.reportSuiteName ?? `testsprite:${projectId}`;
50925094
const xml = buildJUnitReport({
50935095
suiteName,
5094-
classname: opts.projectId,
5096+
classname: projectId,
50955097
results,
50965098
});
50975099
await writeJUnitReportFile(opts.reportFile, xml);
@@ -5159,10 +5161,13 @@ export async function runTestRunAll(
51595161
idempotencyKey,
51605162
...(opts.wait ? { thenPoll: '/api/cli/v1/runs/<run-id>?waitSeconds=25' } : {}),
51615163
};
5162-
out.print(batchRunSample ?? envelope);
51635164
if (opts.report === 'junit' && opts.reportFile !== undefined) {
5164-
await writeJUnitReportFile(opts.reportFile, sampleJUnitReportXml(opts.projectId));
5165+
await writeJUnitReportFile(
5166+
opts.reportFile,
5167+
sampleJUnitReportXml(opts.projectId, opts.reportSuiteName),
5168+
);
51655169
}
5170+
out.print(batchRunSample ?? envelope);
51665171
return undefined;
51675172
}
51685173

@@ -5491,7 +5496,12 @@ export async function runTestRunAll(
54915496
},
54925497
resolveAlternate,
54935498
});
5494-
return { testId: entry.testId, runId, status: finalRun.status };
5499+
return {
5500+
testId: entry.testId,
5501+
runId,
5502+
projectId: finalRun.projectId,
5503+
status: finalRun.status,
5504+
};
54955505
} catch (err) {
54965506
if (err instanceof TimeoutError) {
54975507
return {
@@ -5573,8 +5583,8 @@ export async function runTestRunAll(
55735583
total: pollable.length,
55745584
},
55755585
};
5576-
out.print(jsonPayload);
55775586
await writeBatchJUnitReportIfRequested(opts, freshRunResults);
5587+
out.print(jsonPayload);
55785588

55795589
// Rate-deferred tests were never dispatched → the batch is incomplete (exit 7),
55805590
// mirroring `test rerun --all`. Checked before the failed-run throw so the
@@ -5636,6 +5646,8 @@ export async function runTestRunAll(
56365646
interface CliRerunResult {
56375647
testId: string;
56385648
runId: string;
5649+
/** Observed on polled runs; used for JUnit report naming when --project omitted. */
5650+
projectId?: string;
56395651
/** Terminal status, or 'timeout' for per-run deadline exceeded. */
56405652
status: string;
56415653
/** Set when the test is a closure member (not the user's named test). */
@@ -5751,11 +5763,14 @@ export async function runTestRerun(
57515763
idempotencyKey,
57525764
...(opts.wait ? { thenPoll: `/api/cli/v1/runs/<run-id>?waitSeconds=25` } : {}),
57535765
};
5754-
out.print(findSample('POST', '/api/cli/v1/tests/batch/rerun')?.body() ?? envelope);
57555766
if (opts.report === 'junit' && opts.reportFile !== undefined) {
5756-
const projectKey = opts.projectId ?? 'batch';
5757-
await writeJUnitReportFile(opts.reportFile, sampleJUnitReportXml(projectKey));
5767+
const projectKey = resolveBatchReportProjectId(opts, []);
5768+
await writeJUnitReportFile(
5769+
opts.reportFile,
5770+
sampleJUnitReportXml(projectKey, opts.reportSuiteName),
5771+
);
57585772
}
5773+
out.print(findSample('POST', '/api/cli/v1/tests/batch/rerun')?.body() ?? envelope);
57595774
}
57605775
void client;
57615776
return undefined;
@@ -6655,7 +6670,12 @@ export async function runTestRerun(
66556670
},
66566671
resolveAlternate,
66576672
});
6658-
return { testId: entry.testId, runId: entry.runId, status: finalRun.status };
6673+
return {
6674+
testId: entry.testId,
6675+
runId: entry.runId,
6676+
projectId: finalRun.projectId,
6677+
status: finalRun.status,
6678+
};
66596679
} catch (err) {
66606680
if (err instanceof TimeoutError) {
66616681
return {
@@ -6743,9 +6763,8 @@ export async function runTestRerun(
67436763
total: accepted.length,
67446764
},
67456765
};
6766+
await writeBatchJUnitReportIfRequested(opts, rerunResults);
67466767
out.print(jsonPayload);
6747-
const reportProjectId = opts.projectId ?? 'batch';
6748-
await writeBatchJUnitReportIfRequested({ ...opts, projectId: reportProjectId }, rerunResults);
67496768

67506769
// Determine exit code: timeout (deferred or any timeout) → 7; any fail → 1; all pass → 0
67516770
if (deferred.length > 0 || timedOut > 0) {

src/lib/dry-run/samples.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ describe('sampleJUnitReportXml', () => {
1111
expect(xml).toContain('name="test_fresh_wave_02"');
1212
expect(xml).toContain('failures="1"');
1313
});
14+
15+
it('honors reportSuiteName override', () => {
16+
const xml = sampleJUnitReportXml('proj_dry', 'custom-ci-suite');
17+
expect(xml).toContain('<testsuite name="custom-ci-suite"');
18+
expect(xml).not.toContain('testsprite:proj_dry');
19+
});
1420
});
1521

1622
describe('findSample', () => {

src/lib/dry-run/samples.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,12 @@ export const SAMPLE_DRY_RUN_REQUEST_ID = SAMPLE_REQUEST_ID;
7272
* Canned JUnit XML for batch `--wait --report junit --dry-run`. Mirrors the
7373
* fresh batch-run sample ids so agents can learn the sidecar shape offline.
7474
*/
75-
export function sampleJUnitReportXml(projectId: string = SAMPLE_PROJECT_ID): string {
75+
export function sampleJUnitReportXml(
76+
projectId: string = SAMPLE_PROJECT_ID,
77+
reportSuiteName?: string,
78+
): string {
7679
return buildJUnitReport({
77-
suiteName: `testsprite:${projectId}`,
80+
suiteName: reportSuiteName ?? `testsprite:${projectId}`,
7881
classname: projectId,
7982
results: [
8083
{

src/lib/junit-report.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
buildJUnitReport,
99
escapeXml,
1010
parseJUnitReportFormat,
11+
resolveBatchReportProjectId,
1112
writeJUnitReportFile,
1213
type JUnitTestResult,
1314
} from './junit-report.js';
@@ -61,6 +62,16 @@ describe('assertJUnitReportOptions', () => {
6162
).toThrowError(ApiError);
6263
});
6364

65+
it('rejects report-suite-name without report', () => {
66+
expect(() =>
67+
assertJUnitReportOptions({
68+
reportSuiteName: 'my-suite',
69+
wait: true,
70+
batchPath: true,
71+
}),
72+
).toThrowError(ApiError);
73+
});
74+
6475
it('rejects report on non-batch paths', () => {
6576
expect(() =>
6677
assertJUnitReportOptions({
@@ -90,6 +101,26 @@ describe('assertJUnitReportOptions', () => {
90101
});
91102
});
92103

104+
describe('resolveBatchReportProjectId', () => {
105+
it('prefers explicit projectId', () => {
106+
expect(resolveBatchReportProjectId({ projectId: 'proj_a' }, [])).toBe('proj_a');
107+
});
108+
109+
it('infers from polled run rows', () => {
110+
expect(resolveBatchReportProjectId({}, [{ projectId: 'proj_from_run' }])).toBe('proj_from_run');
111+
});
112+
113+
it('requires --project when the project cannot be inferred', () => {
114+
expect(() => resolveBatchReportProjectId({}, [])).toThrowError(ApiError);
115+
try {
116+
resolveBatchReportProjectId({}, []);
117+
} catch (err) {
118+
expect((err as ApiError).code).toBe('VALIDATION_ERROR');
119+
expect((err as ApiError).exitCode).toBe(5);
120+
}
121+
});
122+
});
123+
93124
describe('buildJUnitReport', () => {
94125
it('renders an empty suite', () => {
95126
const xml = buildJUnitReport({

src/lib/junit-report.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface JUnitTestResult {
1111
testId: string;
1212
runId?: string;
1313
status: string;
14+
/** Observed on polled runs; used for classname when --project is omitted. */
15+
projectId?: string;
1416
error?: { code: string; message: string; exitCode?: number };
1517
}
1618

@@ -49,6 +51,12 @@ export function assertJUnitReportOptions(opts: JUnitReportFlagOptions): void {
4951
if (opts.reportFile !== undefined && opts.reportFile !== '') {
5052
throw localValidationError('report-file', '--report-file requires --report junit');
5153
}
54+
if (opts.reportSuiteName !== undefined && opts.reportSuiteName !== '') {
55+
throw localValidationError(
56+
'report-suite-name',
57+
'--report-suite-name requires --report junit',
58+
);
59+
}
5260
return;
5361
}
5462

@@ -69,6 +77,23 @@ export function assertJUnitReportOptions(opts: JUnitReportFlagOptions): void {
6977
}
7078
}
7179

80+
/**
81+
* Resolve the project id used for JUnit classname / default suite naming.
82+
* Prefer explicit `--project`, then ids observed on polled run rows.
83+
*/
84+
export function resolveBatchReportProjectId(
85+
opts: { projectId?: string },
86+
results: ReadonlyArray<{ projectId?: string }>,
87+
): string {
88+
if (opts.projectId) return opts.projectId;
89+
const fromPoll = results.map(r => r.projectId).find((id): id is string => !!id);
90+
if (fromPoll) return fromPoll;
91+
throw localValidationError(
92+
'project',
93+
'--report junit requires --project <id> when the project cannot be inferred from run results',
94+
);
95+
}
96+
7297
/**
7398
* Escape text for inclusion in XML element bodies and double-quoted attributes.
7499
*/

0 commit comments

Comments
 (0)