Skip to content

Commit ad5c8aa

Browse files
fix(test): address CodeRabbit review on JUnit report export
1 parent f872470 commit ad5c8aa

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 under `--output json`, `--verbose`, or `--debug`); 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
@@ -3767,6 +3767,24 @@ describe('runTestRunAll — JUnit report export', () => {
37673767
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
37683768
});
37693769

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+
37703788
it('--dry-run --report junit writes canned sample XML', async () => {
37713789
const dir = mkdtempSync(join(tmpdir(), 'junit-run-dry-'));
37723790
const reportPath = join(dir, 'results.xml');
@@ -3794,4 +3812,33 @@ describe('runTestRunAll — JUnit report export', () => {
37943812
expect(xml).toContain('name="test_fresh_wave_01"');
37953813
expect(xml).toContain('failures="1"');
37963814
});
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+
});
37973844
});

src/commands/test.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { findSample, sampleJUnitReportXml } from '../lib/dry-run/samples.js';
2222
import {
2323
assertJUnitReportOptions,
2424
buildJUnitReport,
25+
resolveBatchReportProjectId,
2526
writeJUnitReportFile,
2627
type JUnitReportFormat,
2728
parseJUnitReportFormat,
@@ -5098,15 +5099,16 @@ async function writeBatchJUnitReportIfRequested(
50985099
report?: JUnitReportFormat;
50995100
reportFile?: string;
51005101
reportSuiteName?: string;
5101-
projectId: string;
5102+
projectId?: string;
51025103
},
51035104
results: readonly JUnitTestResult[],
51045105
): Promise<void> {
51055106
if (opts.report !== 'junit' || opts.reportFile === undefined) return;
5106-
const suiteName = opts.reportSuiteName ?? `testsprite:${opts.projectId}`;
5107+
const projectId = resolveBatchReportProjectId(opts, results);
5108+
const suiteName = opts.reportSuiteName ?? `testsprite:${projectId}`;
51075109
const xml = buildJUnitReport({
51085110
suiteName,
5109-
classname: opts.projectId,
5111+
classname: projectId,
51105112
results,
51115113
});
51125114
await writeJUnitReportFile(opts.reportFile, xml);
@@ -5174,10 +5176,13 @@ export async function runTestRunAll(
51745176
idempotencyKey,
51755177
...(opts.wait ? { thenPoll: '/api/cli/v1/runs/<run-id>?waitSeconds=25' } : {}),
51765178
};
5177-
out.print(batchRunSample ?? envelope);
51785179
if (opts.report === 'junit' && opts.reportFile !== undefined) {
5179-
await writeJUnitReportFile(opts.reportFile, sampleJUnitReportXml(opts.projectId));
5180+
await writeJUnitReportFile(
5181+
opts.reportFile,
5182+
sampleJUnitReportXml(opts.projectId, opts.reportSuiteName),
5183+
);
51805184
}
5185+
out.print(batchRunSample ?? envelope);
51815186
return undefined;
51825187
}
51835188

@@ -5516,7 +5521,12 @@ export async function runTestRunAll(
55165521
},
55175522
resolveAlternate,
55185523
});
5519-
return { testId: entry.testId, runId, status: finalRun.status };
5524+
return {
5525+
testId: entry.testId,
5526+
runId,
5527+
projectId: finalRun.projectId,
5528+
status: finalRun.status,
5529+
};
55205530
} catch (err) {
55215531
if (err instanceof TimeoutError) {
55225532
return {
@@ -5598,8 +5608,8 @@ export async function runTestRunAll(
55985608
total: pollable.length,
55995609
},
56005610
};
5601-
out.print(jsonPayload);
56025611
await writeBatchJUnitReportIfRequested(opts, freshRunResults);
5612+
out.print(jsonPayload);
56035613

56045614
// Rate-deferred tests were never dispatched → the batch is incomplete (exit 7),
56055615
// mirroring `test rerun --all`. Checked before the failed-run throw so the
@@ -5661,6 +5671,8 @@ export async function runTestRunAll(
56615671
interface CliRerunResult {
56625672
testId: string;
56635673
runId: string;
5674+
/** Observed on polled runs; used for JUnit report naming when --project omitted. */
5675+
projectId?: string;
56645676
/** Terminal status, or 'timeout' for per-run deadline exceeded. */
56655677
status: string;
56665678
/** Set when the test is a closure member (not the user's named test). */
@@ -5776,11 +5788,14 @@ export async function runTestRerun(
57765788
idempotencyKey,
57775789
...(opts.wait ? { thenPoll: `/api/cli/v1/runs/<run-id>?waitSeconds=25` } : {}),
57785790
};
5779-
out.print(findSample('POST', '/api/cli/v1/tests/batch/rerun')?.body() ?? envelope);
57805791
if (opts.report === 'junit' && opts.reportFile !== undefined) {
5781-
const projectKey = opts.projectId ?? 'batch';
5782-
await writeJUnitReportFile(opts.reportFile, sampleJUnitReportXml(projectKey));
5792+
const projectKey = resolveBatchReportProjectId(opts, []);
5793+
await writeJUnitReportFile(
5794+
opts.reportFile,
5795+
sampleJUnitReportXml(projectKey, opts.reportSuiteName),
5796+
);
57835797
}
5798+
out.print(findSample('POST', '/api/cli/v1/tests/batch/rerun')?.body() ?? envelope);
57845799
}
57855800
void client;
57865801
return undefined;
@@ -6677,7 +6692,12 @@ export async function runTestRerun(
66776692
},
66786693
resolveAlternate,
66796694
});
6680-
return { testId: entry.testId, runId: entry.runId, status: finalRun.status };
6695+
return {
6696+
testId: entry.testId,
6697+
runId: entry.runId,
6698+
projectId: finalRun.projectId,
6699+
status: finalRun.status,
6700+
};
66816701
} catch (err) {
66826702
if (err instanceof TimeoutError) {
66836703
return {
@@ -6765,9 +6785,8 @@ export async function runTestRerun(
67656785
total: accepted.length,
67666786
},
67676787
};
6788+
await writeBatchJUnitReportIfRequested(opts, rerunResults);
67686789
out.print(jsonPayload);
6769-
const reportProjectId = opts.projectId ?? 'batch';
6770-
await writeBatchJUnitReportIfRequested({ ...opts, projectId: reportProjectId }, rerunResults);
67716790

67726791
// Determine exit code: timeout (deferred or any timeout) → 7; any fail → 1; all pass → 0
67736792
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)