@@ -39,6 +39,7 @@ interface TestSuite {
3939 skipped : number
4040 total : number
4141 testCases : TestCase [ ]
42+ retries : number
4243}
4344
4445interface SkippedTestSuite {
@@ -53,6 +54,7 @@ interface TestCase {
5354 status : 'passed' | 'failed' | 'skipped'
5455 reason ?: string
5556 link ?: string
57+ retries : number
5658}
5759
5860async function parseXMLFile ( filePath : string ) : Promise < { testsuites : JUnitTestSuites } > {
@@ -66,9 +68,7 @@ const testCount = {
6668 passed : 0 ,
6769}
6870
69- function junitToJson ( xmlData : {
70- testsuites : JUnitTestSuites
71- } ) : Array < TestSuite | SkippedTestSuite > {
71+ function junitToJson ( xmlData : { testsuites : JUnitTestSuites } ) : Array < TestSuite > {
7272 if ( ! xmlData . testsuites ) {
7373 return [ ]
7474 }
@@ -78,38 +78,57 @@ function junitToJson(xmlData: {
7878 : [ xmlData . testsuites . testsuite ]
7979
8080 return testSuites . map ( ( suite ) => {
81- const { '@tests' : tests , '@failures' : failed , '@name' : name } = suite
82-
83- const passed = tests - failed - suite [ '@skipped' ]
84-
81+ const total = Number ( suite [ '@tests' ] )
82+ const failed = Number ( suite [ '@failures' ] ) + Number ( suite [ '@errors' ] )
83+ const name = suite [ '@name' ]
8584 const testCases = Array . isArray ( suite . testcase ) ? suite . testcase : [ suite . testcase ]
8685
86+ const passed = total - failed - suite [ '@skipped' ]
8787 const testSuite : TestSuite = {
8888 name,
8989 file : testCases [ 0 ] ?. [ '@file' ] ,
9090 passed,
91- failed : Number ( failed ) ,
91+ failed,
92+ // The XML file contains a count of "skipped" tests, but we actually want to report on what WE
93+ // want to "skip" (i.e. the tests that are marked as skipped in `test-config.json`). This is
94+ // confusing and we should probably use a different term for our concept.
9295 skipped : 0 ,
93- total : tests ,
96+ total,
9497 testCases : [ ] ,
98+ // This is computed below by detecting duplicates
99+ retries : 0 ,
95100 }
96- const skippedTestsForFile = testConfig . skipped . find (
101+ const skipConfigForFile = testConfig . skipped . find (
97102 ( skippedTest ) => skippedTest . file === testSuite . file ,
98103 )
104+ const isEntireSuiteSkipped = skipConfigForFile != null && skipConfigForFile . tests == null
105+ const skippedTestsForFile =
106+ skipConfigForFile ?. tests ?. map ( ( skippedTest ) => {
107+ // The config supports both a bare string and an object
108+ if ( typeof skippedTest === 'string' ) {
109+ return { name : skippedTest , reason : skipConfigForFile . reason ?? null }
110+ }
111+ return skippedTest
112+ } ) ?? [ ]
99113
100114 // If the skipped file has no `tests`, all tests in the file are skipped
101- testSuite . skipped =
102- skippedTestsForFile != null ? ( skippedTestsForFile . tests ?? testCases ) . length : 0
115+ testSuite . skipped = isEntireSuiteSkipped ? testCases . length : skippedTestsForFile . length
103116
104117 for ( const testCase of testCases ) {
118+ // Omit tests skipped in the Next.js repo itself
105119 if ( 'skipped' in testCase ) {
106120 continue
107121 }
122+ // Omit tests we've marked as "skipped" in `test-config.json`
123+ if ( skippedTestsForFile ?. some ( ( { name } ) => name === testCase [ '@name' ] ) ) {
124+ continue
125+ }
108126 const status = testCase . failure ? 'failed' : 'passed'
109127 testCount [ status ] ++
110128 const test : TestCase = {
111129 name : testCase [ '@name' ] ,
112130 status,
131+ retries : 0 ,
113132 }
114133 if ( status === 'failed' ) {
115134 const failure = testConfig . failures . find (
@@ -123,36 +142,74 @@ function junitToJson(xmlData: {
123142 testSuite . testCases . push ( test )
124143 }
125144
126- if ( skippedTestsForFile ?. tests ) {
127- testCount . skipped += skippedTestsForFile . tests . length
145+ if ( ! isEntireSuiteSkipped && skippedTestsForFile . length > 0 ) {
146+ testCount . skipped += skippedTestsForFile . length
128147 testSuite . testCases . push (
129- ...skippedTestsForFile . tests . map ( ( test ) : TestCase => {
130- if ( typeof test === 'string' ) {
131- return {
132- name : test ,
133- status : 'skipped' ,
134- reason : skippedTestsForFile . reason ,
135- }
136- }
137- return {
148+ ...skippedTestsForFile . map (
149+ ( test ) : TestCase => ( {
138150 name : test . name ,
139151 status : 'skipped' ,
140152 reason : test . reason ,
141- }
142- } ) ,
153+ retries : 0 ,
154+ } ) ,
155+ ) ,
143156 )
144- } else if ( skippedTestsForFile != null ) {
145- // If `tests` is omitted, all tests in the file are skipped
157+ } else if ( isEntireSuiteSkipped ) {
146158 testCount . skipped += testSuite . total
147159 }
148160 return testSuite
149161 } )
150162}
151163
164+ function mergeTestResults ( result1 : TestSuite , result2 : TestSuite ) : TestSuite {
165+ if ( result1 . file !== result2 . file ) {
166+ throw new Error ( 'Cannot merge results for different files' )
167+ }
168+ if ( result1 . name !== result2 . name ) {
169+ throw new Error ( 'Cannot merge results for different suites' )
170+ }
171+ if ( result1 . total !== result2 . total ) {
172+ throw new Error ( 'Cannot merge results with different total test counts' )
173+ }
174+
175+ // Return the run result with the fewest failures.
176+ // We could merge at the individual test level across runs, but then we'd need to re-calculate
177+ // all the total counts, and that probably isn't worth the complexity.
178+ const bestResult = result1 . failed < result2 . failed ? result1 : result2
179+ const retries = result1 . retries + result2 . retries + 1
180+ return {
181+ ...bestResult ,
182+ retries,
183+ ...( bestResult . testCases == null
184+ ? { }
185+ : {
186+ testCases : bestResult . testCases . map ( ( testCase ) => ( {
187+ ...testCase ,
188+ retries,
189+ } ) ) ,
190+ } ) ,
191+ }
192+ }
193+
194+ // When a test is run multiple times (due to retries), the test runner outputs a separate entry
195+ // for each run. Merge them into a single entry.
196+ function dedupeTestResults ( results : Array < TestSuite > ) : Array < TestSuite > {
197+ const resultsByFile = new Map < string , TestSuite > ( )
198+ for ( const result of results ) {
199+ const existingResult = resultsByFile . get ( result . file )
200+ if ( existingResult == null ) {
201+ resultsByFile . set ( result . file , result )
202+ } else {
203+ resultsByFile . set ( result . file , mergeTestResults ( existingResult , result ) )
204+ }
205+ }
206+ return [ ...resultsByFile . values ( ) ]
207+ }
208+
152209async function processJUnitFiles (
153210 directoryPath : string ,
154211) : Promise < Array < TestSuite | SkippedTestSuite > > {
155- const results : ( TestSuite | SkippedTestSuite ) [ ] = [ ]
212+ const results : TestSuite [ ] = [ ]
156213 for await ( const file of expandGlob ( `${ directoryPath } /**/*.xml` ) ) {
157214 const xmlData = await parseXMLFile ( file . path )
158215 results . push ( ...junitToJson ( xmlData ) )
@@ -170,9 +227,8 @@ async function processJUnitFiles(
170227 skipped : true ,
171228 } ) ,
172229 )
173- results . push ( ...skippedSuites )
174230
175- return results
231+ return [ ... dedupeTestResults ( results ) , ... skippedSuites ]
176232}
177233
178234// Get the directory path from the command-line arguments
0 commit comments