Skip to content

Commit 0154add

Browse files
feat(junit): add includeRetries option to emit each retry as separate testcase (microsoft#39464)
1 parent e815571 commit 0154add

4 files changed

Lines changed: 206 additions & 54 deletions

File tree

packages/playwright/src/reporters/junit.ts

Lines changed: 122 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { stripAnsiEscapes } from '../util';
2424

2525
import type { ReporterV2 } from './reporterV2';
2626
import type { JUnitReporterOptions } from '../../types/test';
27-
import type { FullConfig, FullResult, Suite, TestCase } from '../../types/testReporter';
27+
import type { FullConfig, FullResult, Suite, TestCase, TestResult } from '../../types/testReporter';
2828

2929
class JUnitReporter implements ReporterV2 {
3030
private config!: FullConfig;
@@ -38,10 +38,12 @@ class JUnitReporter implements ReporterV2 {
3838
private resolvedOutputFile: string | undefined;
3939
private stripANSIControlSequences = false;
4040
private includeProjectInTestName = false;
41+
private includeRetries = false;
4142

4243
constructor(options: JUnitReporterOptions & CommonReporterOptions) {
4344
this.stripANSIControlSequences = getAsBooleanFromENV('PLAYWRIGHT_JUNIT_STRIP_ANSI', !!options.stripANSIControlSequences);
4445
this.includeProjectInTestName = getAsBooleanFromENV('PLAYWRIGHT_JUNIT_INCLUDE_PROJECT_IN_TEST_NAME', !!options.includeProjectInTestName);
46+
this.includeRetries = getAsBooleanFromENV('PLAYWRIGHT_JUNIT_INCLUDE_RETRIES', !!options.includeRetries);
4547
this.configDir = options.configDir;
4648
this.resolvedOutputFile = resolveOutputFile('JUNIT', options)?.outputFile;
4749
}
@@ -143,14 +145,24 @@ class JUnitReporter implements ReporterV2 {
143145
}
144146

145147
private async _addTestCase(suiteName: string, namePrefix: string, test: TestCase, entries: XMLEntry[]): Promise<'failure' | 'error' | null> {
148+
const isRetried = this.includeRetries && test.results.length > 1;
149+
const isFlaky = isRetried && test.ok();
150+
146151
const entry = {
147152
name: 'testcase',
148153
attributes: {
149154
// Skip root, project, file
150155
name: namePrefix + test.titlePath().slice(3).join(' › '),
151156
// filename
152157
classname: suiteName,
153-
time: (test.results.reduce((acc, value) => acc + value.duration, 0)) / 1000
158+
// For flaky tests, use the last (successful) result's duration.
159+
// For permanent failures with retries, use the first result's duration.
160+
// Otherwise, use total duration across all results.
161+
time: isFlaky
162+
? test.results[test.results.length - 1].duration / 1000
163+
: isRetried
164+
? test.results[0].duration / 1000
165+
: (test.results.reduce((acc, value) => acc + value.duration, 0)) / 1000
154166

155167
},
156168
children: [] as XMLEntry[]
@@ -185,34 +197,40 @@ class JUnitReporter implements ReporterV2 {
185197
}
186198

187199
let classification: 'failure' | 'error' | null = null;
188-
if (!test.ok()) {
189-
const errorInfo = classifyError(test);
190-
if (errorInfo) {
191-
classification = errorInfo.elementName;
192-
entry.children.push({
193-
name: errorInfo.elementName,
194-
attributes: {
195-
message: errorInfo.message,
196-
type: errorInfo.type,
197-
},
198-
text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test))
199-
});
200-
} else {
201-
classification = 'failure';
202-
entry.children.push({
203-
name: 'failure',
204-
attributes: {
205-
message: `${path.basename(test.location.file)}:${test.location.line}:${test.location.column} ${test.title}`,
206-
type: 'FAILURE',
207-
},
208-
text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test))
209-
});
200+
201+
if (isFlaky) {
202+
// Flaky test (eventually passed): use Maven Surefire <flakyFailure>/<flakyError>.
203+
// No <failure> element — flaky tests count as passed.
204+
for (const result of test.results) {
205+
if (result.status === 'passed' || result.status === 'skipped')
206+
continue;
207+
entry.children.push(buildSurefireRetryEntry(result, 'flaky'));
210208
}
209+
// classification stays null — flaky tests are not counted as failures.
210+
} else if (isRetried) {
211+
// Permanent failure (failed all retries): use <failure> + Maven Surefire <rerunFailure>/<rerunError>.
212+
classification = this._addFailureEntry(test, entry);
213+
// Add <rerunFailure>/<rerunError> for each subsequent retry.
214+
for (let i = 1; i < test.results.length; i++) {
215+
const result = test.results[i];
216+
if (result.status === 'passed' || result.status === 'skipped')
217+
continue;
218+
entry.children.push(buildSurefireRetryEntry(result, 'rerun'));
219+
}
220+
} else if (!test.ok()) {
221+
// Standard failure (no retries, or includeRetries is false).
222+
classification = this._addFailureEntry(test, entry);
211223
}
212224

213225
const systemOut: string[] = [];
214226
const systemErr: string[] = [];
215-
for (const result of test.results) {
227+
// When retries are included, top-level output comes from the primary result only:
228+
// flaky → last (successful) result; permanent failure → first result.
229+
// Without retries: all results (original behavior).
230+
const outputResults = isRetried
231+
? [isFlaky ? test.results[test.results.length - 1] : test.results[0]]
232+
: test.results;
233+
for (const result of outputResults) {
216234
for (const item of result.stdout)
217235
systemOut.push(item.toString());
218236
for (const item of result.stderr)
@@ -245,40 +263,92 @@ class JUnitReporter implements ReporterV2 {
245263
entry.children.push({ name: 'system-err', text: systemErr.join('') });
246264
return classification;
247265
}
248-
}
249266

250-
function classifyError(test: TestCase): { elementName: 'failure' | 'error'; type: string; message: string } | null {
251-
for (const result of test.results) {
252-
const error = result.error;
253-
if (!error)
254-
continue;
255-
256-
const rawMessage = stripAnsiEscapes(error.message || error.value || '');
257-
258-
// Parse "ErrorName: message" format from serialized error.
259-
const nameMatch = rawMessage.match(/^(\w+): /);
260-
const errorName = nameMatch ? nameMatch[1] : '';
261-
const messageBody = nameMatch ? rawMessage.slice(nameMatch[0].length) : rawMessage;
262-
const firstLine = messageBody.split('\n')[0].trim();
263-
264-
// Check for expect/assertion failure pattern.
265-
const matcherMatch = rawMessage.match(/expect\(.*?\)\.(not\.)?(\w+)/);
266-
if (matcherMatch) {
267-
const matcherName = `expect.${matcherMatch[1] || ''}${matcherMatch[2]}`;
268-
return {
269-
elementName: 'failure',
270-
type: matcherName,
271-
message: firstLine,
272-
};
267+
private _addFailureEntry(test: TestCase, entry: XMLEntry): 'failure' | 'error' {
268+
const errorInfo = classifyError(test);
269+
if (errorInfo) {
270+
entry.children!.push({
271+
name: errorInfo.elementName,
272+
attributes: { message: errorInfo.message, type: errorInfo.type },
273+
text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test))
274+
});
275+
return errorInfo.elementName;
273276
}
277+
entry.children!.push({
278+
name: 'failure',
279+
attributes: {
280+
message: `${path.basename(test.location.file)}:${test.location.line}:${test.location.column} ${test.title}`,
281+
type: 'FAILURE',
282+
},
283+
text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test))
284+
});
285+
return 'failure';
286+
}
287+
288+
}
274289

275-
// Thrown error.
290+
/**
291+
* Builds a Maven Surefire retry entry (<flakyFailure>/<flakyError> or <rerunFailure>/<rerunError>)
292+
* with per-result stackTrace, system-out, and system-err as children.
293+
*/
294+
function buildSurefireRetryEntry(result: TestResult, prefix: 'flaky' | 'rerun'): XMLEntry {
295+
const errorInfo = classifyResultError(result);
296+
const baseName = errorInfo?.elementName === 'error' ? 'Error' : 'Failure';
297+
const elementName = `${prefix}${baseName}`;
298+
const children: XMLEntry[] = [];
299+
const stackTrace = result.error?.stack || result.error?.message || result.error?.value || '';
300+
children.push({ name: 'stackTrace', text: stripAnsiEscapes(stackTrace) });
301+
const resultOut = result.stdout.map(s => s.toString()).join('');
302+
const resultErr = result.stderr.map(s => s.toString()).join('');
303+
if (resultOut)
304+
children.push({ name: 'system-out', text: resultOut });
305+
if (resultErr)
306+
children.push({ name: 'system-err', text: resultErr });
307+
return {
308+
name: elementName,
309+
attributes: { message: errorInfo?.message || '', type: errorInfo?.type || 'FAILURE', time: result.duration / 1000 },
310+
children,
311+
};
312+
}
313+
314+
function classifyResultError(result: TestResult): { elementName: 'failure' | 'error'; type: string; message: string } | null {
315+
const error = result.error;
316+
if (!error)
317+
return null;
318+
319+
const rawMessage = stripAnsiEscapes(error.message || error.value || '');
320+
321+
// Parse "ErrorName: message" format from serialized error.
322+
const nameMatch = rawMessage.match(/^(\w+): /);
323+
const errorName = nameMatch ? nameMatch[1] : '';
324+
const messageBody = nameMatch ? rawMessage.slice(nameMatch[0].length) : rawMessage;
325+
const firstLine = messageBody.split('\n')[0].trim();
326+
327+
// Check for expect/assertion failure pattern.
328+
const matcherMatch = rawMessage.match(/expect\(.*?\)\.(not\.)?(\w+)/);
329+
if (matcherMatch) {
330+
const matcherName = `expect.${matcherMatch[1] || ''}${matcherMatch[2]}`;
276331
return {
277-
elementName: 'error',
278-
type: errorName || 'Error',
332+
elementName: 'failure',
333+
type: matcherName,
279334
message: firstLine,
280335
};
281336
}
337+
338+
// Thrown error.
339+
return {
340+
elementName: 'error',
341+
type: errorName || 'Error',
342+
message: firstLine,
343+
};
344+
}
345+
346+
function classifyError(test: TestCase): { elementName: 'failure' | 'error'; type: string; message: string } | null {
347+
for (const result of test.results) {
348+
const info = classifyResultError(result);
349+
if (info)
350+
return info;
351+
}
282352
return null;
283353
}
284354

packages/playwright/types/test.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export * from 'playwright-core';
2020

2121
export type BlobReporterOptions = { outputDir?: string, fileName?: string };
2222
export type ListReporterOptions = { printSteps?: boolean };
23-
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };
23+
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean, includeRetries?: boolean };
2424
export type JsonReporterOptions = { outputFile?: string };
2525
export type HtmlReporterOptions = {
2626
outputFolder?: string;

tests/playwright-test/reporter-junit.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,5 +633,87 @@ for (const useIntermediateMergeReport of [false, true] as const) {
633633
expect(time).toBe(result.report.stats.duration / 1000);
634634
expect(time).toBeGreaterThan(1);
635635
});
636+
637+
test('should emit flakyFailure for flaky tests when includeRetries is enabled', async ({ runInlineTest }) => {
638+
const result = await runInlineTest({
639+
'playwright.config.ts': `
640+
module.exports = {
641+
retries: 2,
642+
reporter: [['junit', { includeRetries: true }]]
643+
};
644+
`,
645+
'a.test.js': `
646+
import { test, expect } from '@playwright/test';
647+
test('one', async ({}, testInfo) => {
648+
expect(testInfo.retry).toBe(2);
649+
});
650+
`,
651+
}, { reporter: '' });
652+
const xml = parseXML(result.output);
653+
// Single testcase for the test; flaky tests count as passed.
654+
expect(xml['testsuites']['$']['tests']).toBe('1');
655+
expect(xml['testsuites']['$']['failures']).toBe('0');
656+
expect(xml['testsuites']['testsuite'][0]['$']['tests']).toBe('1');
657+
expect(xml['testsuites']['testsuite'][0]['$']['failures']).toBe('0');
658+
const testcase = xml['testsuites']['testsuite'][0]['testcase'][0];
659+
expect(testcase['$']['name']).toBe('one');
660+
// No <failure> element — test eventually passed.
661+
expect(testcase['failure']).toBeFalsy();
662+
// Two <flakyFailure> elements for the two failed attempts.
663+
expect(testcase['flakyFailure'].length).toBe(2);
664+
expect(testcase['flakyFailure'][0]['stackTrace']).toBeTruthy();
665+
expect(testcase['flakyFailure'][1]['stackTrace']).toBeTruthy();
666+
expect(result.exitCode).toBe(0);
667+
});
668+
669+
test('should not include retries by default', async ({ runInlineTest }) => {
670+
const result = await runInlineTest({
671+
'a.test.js': `
672+
import { test, expect } from '@playwright/test';
673+
test('one', async ({}, testInfo) => {
674+
expect(testInfo.retry).toBe(1);
675+
});
676+
`,
677+
}, { retries: 1, reporter: 'junit' });
678+
const xml = parseXML(result.output);
679+
// Default behavior: single testcase, no flakyFailure elements.
680+
expect(xml['testsuites']['$']['tests']).toBe('1');
681+
const testcases = xml['testsuites']['testsuite'][0]['testcase'];
682+
expect(testcases.length).toBe(1);
683+
expect(testcases[0]['$']['name']).toBe('one');
684+
expect(testcases[0]['flakyFailure']).toBeFalsy();
685+
expect(result.exitCode).toBe(0);
686+
});
687+
688+
test('should emit rerunFailure for permanent failures when includeRetries is enabled', async ({ runInlineTest }) => {
689+
const result = await runInlineTest({
690+
'playwright.config.ts': `
691+
module.exports = {
692+
retries: 1,
693+
reporter: [['junit', { includeRetries: true }]]
694+
};
695+
`,
696+
'a.test.js': `
697+
import { test, expect } from '@playwright/test';
698+
test('one', async ({}) => {
699+
expect(1).toBe(0);
700+
});
701+
`,
702+
}, { reporter: '' });
703+
const xml = parseXML(result.output);
704+
// Single testcase; permanent failure counts as 1 test with 1 failure.
705+
expect(xml['testsuites']['$']['tests']).toBe('1');
706+
expect(xml['testsuites']['$']['failures']).toBe('1');
707+
expect(xml['testsuites']['testsuite'][0]['$']['tests']).toBe('1');
708+
expect(xml['testsuites']['testsuite'][0]['$']['failures']).toBe('1');
709+
const testcase = xml['testsuites']['testsuite'][0]['testcase'][0];
710+
expect(testcase['$']['name']).toBe('one');
711+
// <failure> for the first attempt.
712+
expect(testcase['failure']).toBeTruthy();
713+
// <rerunFailure> for the retry.
714+
expect(testcase['rerunFailure'].length).toBe(1);
715+
expect(testcase['rerunFailure'][0]['stackTrace']).toBeTruthy();
716+
expect(result.exitCode).toBe(1);
717+
});
636718
});
637719
}

utils/generate_types/overrides-test.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export * from 'playwright-core';
1919

2020
export type BlobReporterOptions = { outputDir?: string, fileName?: string };
2121
export type ListReporterOptions = { printSteps?: boolean };
22-
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean };
22+
export type JUnitReporterOptions = { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean, includeRetries?: boolean };
2323
export type JsonReporterOptions = { outputFile?: string };
2424
export type HtmlReporterOptions = {
2525
outputFolder?: string;

0 commit comments

Comments
 (0)