Skip to content

Commit c65f4ca

Browse files
9aoyCopilot
andauthored
feat: add JUnit reporter (#528)
Co-authored-by: Copilot <[email protected]>
1 parent cdbd15e commit c65f4ca

File tree

12 files changed

+656
-13
lines changed

12 files changed

+656
-13
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from '@rstest/core';
2+
3+
describe('Junit test', () => {
4+
it('should pass', () => {
5+
expect(1 + 1).toBe(2);
6+
});
7+
8+
it('should fail', () => {
9+
expect('hi').toBe('hii');
10+
});
11+
12+
it.skip('should skip', () => {
13+
expect(1 + 1).toBe(3);
14+
});
15+
});

e2e/reporter/junit.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect, it } from '@rstest/core';
2+
import { runRstestCli } from '../scripts';
3+
4+
it('junit', async () => {
5+
const { cli, expectLog } = await runRstestCli({
6+
command: 'rstest',
7+
args: ['run', 'junit', '--reporter', 'junit'],
8+
options: {
9+
nodeOptions: {
10+
cwd: __dirname,
11+
},
12+
},
13+
});
14+
15+
await cli.exec;
16+
expect(cli.exec.process?.exitCode).toBe(1);
17+
18+
const logs = cli.stdout.split('\n').filter(Boolean);
19+
20+
expectLog('<?xml version="1.0" encoding="UTF-8"?>', logs);
21+
22+
expectLog('<failure', logs);
23+
expectLog('<skipped/>', logs);
24+
});

packages/core/LICENSE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,20 @@ Licensed under MIT license in the repository at git+https://github.com/errwischt
928928
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
929929
> SOFTWARE.
930930
931+
### strip-ansi
932+
933+
Licensed under MIT license.
934+
935+
> MIT License
936+
>
937+
> Copyright (c) Sindre Sorhus <[email protected]> (https://sindresorhus.com)
938+
>
939+
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
940+
>
941+
> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
942+
>
943+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
944+
931945
### supports-color
932946

933947
Licensed under MIT license.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"rslog": "^1.2.11",
8282
"source-map-support": "^0.5.21",
8383
"stacktrace-parser": "0.1.11",
84+
"strip-ansi": "^7.1.0",
8485
"tinyglobby": "^0.2.14",
8586
"tinyspy": "^4.0.3"
8687
},

packages/core/src/core/rstest.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isCI } from 'std-env';
33
import { withDefaultConfig } from '../config';
44
import { DefaultReporter } from '../reporter';
55
import { GithubActionsReporter } from '../reporter/githubActions';
6+
import { JUnitReporter } from '../reporter/junit';
67
import { VerboseReporter } from '../reporter/verbose';
78
import type {
89
NormalizedConfig,
@@ -39,7 +40,7 @@ export class Rstest implements RstestContext {
3940
public command: RstestCommand;
4041
public fileFilters?: string[];
4142
public configFilePath?: string;
42-
public reporters: (Reporter | GithubActionsReporter)[];
43+
public reporters: (Reporter | GithubActionsReporter | JUnitReporter)[];
4344
public snapshotManager: SnapshotManager;
4445
public version: string;
4546
public rootPath: string;
@@ -159,18 +160,20 @@ const reportersMap: {
159160
default: typeof DefaultReporter;
160161
verbose: typeof VerboseReporter;
161162
'github-actions': typeof GithubActionsReporter;
163+
junit: typeof JUnitReporter;
162164
} = {
163165
default: DefaultReporter,
164166
verbose: VerboseReporter,
165167
'github-actions': GithubActionsReporter,
168+
junit: JUnitReporter,
166169
};
167170

168171
export type BuiltInReporterNames = keyof typeof reportersMap;
169172

170173
export function createReporters(
171174
reporters: RstestConfig['reporters'],
172175
initOptions: any = {},
173-
): (Reporter | GithubActionsReporter)[] {
176+
): (Reporter | GithubActionsReporter | JUnitReporter)[] {
174177
const result = castArray(reporters).map((reporter) => {
175178
if (typeof reporter === 'string' || Array.isArray(reporter)) {
176179
const [name, options = {}] =
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { writeFile } from 'node:fs/promises';
2+
import { relative } from 'pathe';
3+
import stripAnsi from 'strip-ansi';
4+
import type {
5+
Duration,
6+
GetSourcemap,
7+
Reporter,
8+
TestFileResult,
9+
TestResult,
10+
} from '../types';
11+
import { getTaskNameWithPrefix } from '../utils';
12+
import { formatStack, parseErrorStacktrace } from '../utils/error';
13+
14+
interface JUnitTestCase {
15+
name: string;
16+
classname: string;
17+
time: number;
18+
status: string;
19+
errors?: {
20+
message: string;
21+
type: string;
22+
details?: string;
23+
}[];
24+
}
25+
26+
interface JUnitTestSuite {
27+
name: string;
28+
tests: number;
29+
failures: number;
30+
errors: number;
31+
skipped: number;
32+
time: number;
33+
timestamp: string;
34+
testcases: JUnitTestCase[];
35+
}
36+
37+
interface JUnitReport {
38+
testsuites: {
39+
name: string;
40+
tests: number;
41+
failures: number;
42+
errors: number;
43+
skipped: number;
44+
time: number;
45+
timestamp: string;
46+
testsuite: JUnitTestSuite[];
47+
};
48+
}
49+
50+
export class JUnitReporter implements Reporter {
51+
private rootPath: string;
52+
private outputPath?: string;
53+
54+
constructor({
55+
rootPath,
56+
options: { outputPath } = {},
57+
}: {
58+
rootPath: string;
59+
options?: { outputPath?: string };
60+
}) {
61+
this.rootPath = rootPath;
62+
this.outputPath = outputPath;
63+
}
64+
65+
private sanitizeXml(text: string): string {
66+
let result = '';
67+
68+
// XML 1.0 valid chars: \x09 | \x0A | \x0D | [\x20-\uD7FF] | [\uE000-\uFFFD] | [\u{10000}-\u{10FFFF}]
69+
// Iterate code points to keep valid ones and drop invalid (e.g., 0x1B, other control chars)
70+
for (const ch of stripAnsi(text)) {
71+
const cp = ch.codePointAt(0)!;
72+
const valid =
73+
cp === 0x09 ||
74+
cp === 0x0a ||
75+
cp === 0x0d ||
76+
(cp >= 0x20 && cp <= 0xd7ff) ||
77+
(cp >= 0xe000 && cp <= 0xfffd) ||
78+
(cp >= 0x10000 && cp <= 0x10ffff);
79+
if (valid) {
80+
result += ch;
81+
}
82+
}
83+
return result;
84+
}
85+
86+
private escapeXml(text: string): string {
87+
const sanitized = this.sanitizeXml(text);
88+
return sanitized
89+
.replace(/&/g, '&amp;')
90+
.replace(/</g, '&lt;')
91+
.replace(/>/g, '&gt;')
92+
.replace(/"/g, '&quot;')
93+
.replace(/'/g, '&apos;');
94+
}
95+
96+
private async createJUnitTestCase(
97+
test: TestResult,
98+
getSourcemap: GetSourcemap,
99+
): Promise<JUnitTestCase> {
100+
const testCase: JUnitTestCase = {
101+
name: getTaskNameWithPrefix(test),
102+
classname: relative(this.rootPath, test.testPath),
103+
time: (test.duration || 0) / 1000, // Convert to seconds
104+
status: test.status,
105+
};
106+
107+
if (test.errors && test.errors.length > 0) {
108+
testCase.errors = await Promise.all(
109+
test.errors.map(async (error) => {
110+
let details = `${error.message}${error.diff ? `\n${error.diff}` : ''}`;
111+
const stackFrames = error.stack
112+
? await parseErrorStacktrace({
113+
stack: error.stack,
114+
fullStack: error.fullStack,
115+
getSourcemap,
116+
})
117+
: [];
118+
119+
if (stackFrames[0]) {
120+
details += `\n${formatStack(stackFrames[0], this.rootPath)}`;
121+
}
122+
123+
return {
124+
message: this.escapeXml(error.message),
125+
type: error.name || 'Error',
126+
details: this.escapeXml(details),
127+
};
128+
}),
129+
);
130+
}
131+
132+
return testCase;
133+
}
134+
135+
private async createJUnitTestSuite(
136+
fileResult: TestFileResult,
137+
getSourcemap: GetSourcemap,
138+
): Promise<JUnitTestSuite> {
139+
const testCases = await Promise.all(
140+
fileResult.results.map(async (test) =>
141+
this.createJUnitTestCase(test, getSourcemap),
142+
),
143+
);
144+
145+
const failures = testCases.filter((test) => test.status === 'fail').length;
146+
const errors = 0; // No separate error tracking; set to 0 for clarity
147+
const skipped = testCases.filter(
148+
(test) => test.status === 'skip' || test.status === 'todo',
149+
).length;
150+
const totalTime = testCases.reduce((sum, test) => sum + test.time, 0);
151+
152+
return {
153+
name: relative(this.rootPath, fileResult.testPath),
154+
tests: testCases.length,
155+
failures,
156+
errors,
157+
skipped,
158+
time: totalTime,
159+
timestamp: new Date().toISOString(),
160+
testcases: testCases,
161+
};
162+
}
163+
164+
private generateJUnitXml(report: JUnitReport): string {
165+
const xmlDeclaration = '<?xml version="1.0" encoding="UTF-8"?>';
166+
167+
const testsuitesXml = `
168+
<testsuites name="${this.escapeXml(report.testsuites.name)}" tests="${report.testsuites.tests}" failures="${report.testsuites.failures}" errors="${report.testsuites.errors}" skipped="${report.testsuites.skipped}" time="${report.testsuites.time}" timestamp="${this.escapeXml(report.testsuites.timestamp)}">`;
169+
170+
const testsuiteXmls = report.testsuites.testsuite
171+
.map((suite) => {
172+
const testsuiteStart = `
173+
<testsuite name="${this.escapeXml(suite.name)}" tests="${suite.tests}" failures="${suite.failures}" errors="${suite.errors}" skipped="${suite.skipped}" time="${suite.time}" timestamp="${this.escapeXml(suite.timestamp)}">`;
174+
175+
const testcaseXmls = suite.testcases
176+
.map((testcase) => {
177+
let testcaseXml = `
178+
<testcase name="${this.escapeXml(testcase.name)}" classname="${this.escapeXml(testcase.classname)}" time="${testcase.time}">`;
179+
180+
if (testcase.status === 'skip' || testcase.status === 'todo') {
181+
testcaseXml += `
182+
<skipped/>`;
183+
} else if (testcase.status === 'fail' && testcase.errors) {
184+
testcase.errors.forEach((error) => {
185+
testcaseXml += `
186+
<failure message="${error.message}" type="${error.type}">${error.details || ''}</failure>`;
187+
});
188+
}
189+
190+
testcaseXml += `
191+
</testcase>`;
192+
return testcaseXml;
193+
})
194+
.join('');
195+
196+
const testsuiteEnd = `
197+
</testsuite>`;
198+
199+
return testsuiteStart + testcaseXmls + testsuiteEnd;
200+
})
201+
.join('');
202+
203+
const testsuitesEnd = `
204+
</testsuites>`;
205+
206+
return xmlDeclaration + testsuitesXml + testsuiteXmls + testsuitesEnd;
207+
}
208+
209+
async onTestRunEnd({
210+
results,
211+
testResults,
212+
duration,
213+
getSourcemap,
214+
}: {
215+
getSourcemap: GetSourcemap;
216+
results: TestFileResult[];
217+
testResults: TestResult[];
218+
duration: Duration;
219+
}): Promise<void> {
220+
const testSuites = await Promise.all(
221+
results.map(async (fileResult) =>
222+
this.createJUnitTestSuite(fileResult, getSourcemap),
223+
),
224+
);
225+
226+
const totalTests = testResults.length;
227+
const totalFailures = testResults.filter(
228+
(test) => test.status === 'fail',
229+
).length;
230+
const totalErrors = 0; // This framework does not distinguish between failures and errors, so errors are always reported as zero.
231+
const totalSkipped = testResults.filter(
232+
(test) => test.status === 'skip' || test.status === 'todo',
233+
).length;
234+
const totalTime = duration.testTime / 1000; // Convert to seconds
235+
236+
const report: JUnitReport = {
237+
testsuites: {
238+
name: 'rstest tests',
239+
tests: totalTests,
240+
failures: totalFailures,
241+
errors: totalErrors,
242+
skipped: totalSkipped,
243+
time: totalTime,
244+
timestamp: new Date().toISOString(),
245+
testsuite: testSuites,
246+
},
247+
};
248+
249+
const xmlContent = this.generateJUnitXml(report);
250+
251+
if (this.outputPath) {
252+
try {
253+
await writeFile(this.outputPath, xmlContent, 'utf-8');
254+
console.log(`JUnit XML report written to: ${this.outputPath}`);
255+
} catch (error) {
256+
console.error(
257+
`Failed to write JUnit XML report to ${this.outputPath}:`,
258+
error,
259+
);
260+
// Fallback to console output
261+
console.log('JUnit XML Report:');
262+
console.log(xmlContent);
263+
}
264+
} else {
265+
// Output to console by default
266+
console.log(xmlContent);
267+
}
268+
}
269+
}

0 commit comments

Comments
 (0)