@@ -24,7 +24,7 @@ import { stripAnsiEscapes } from '../util';
2424
2525import type { ReporterV2 } from './reporterV2' ;
2626import 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
2929class 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 ( / e x p e c t \( .* ?\) \. ( n o t \. ) ? ( \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 ( / e x p e c t \( .* ?\) \. ( n o t \. ) ? ( \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
0 commit comments