@@ -39,6 +39,7 @@ interface TestSuite {
39
39
skipped : number
40
40
total : number
41
41
testCases : TestCase [ ]
42
+ retries : number
42
43
}
43
44
44
45
interface SkippedTestSuite {
@@ -53,6 +54,7 @@ interface TestCase {
53
54
status : 'passed' | 'failed' | 'skipped'
54
55
reason ?: string
55
56
link ?: string
57
+ retries : number
56
58
}
57
59
58
60
async function parseXMLFile ( filePath : string ) : Promise < { testsuites : JUnitTestSuites } > {
@@ -66,9 +68,7 @@ const testCount = {
66
68
passed : 0 ,
67
69
}
68
70
69
- function junitToJson ( xmlData : {
70
- testsuites : JUnitTestSuites
71
- } ) : Array < TestSuite | SkippedTestSuite > {
71
+ function junitToJson ( xmlData : { testsuites : JUnitTestSuites } ) : Array < TestSuite > {
72
72
if ( ! xmlData . testsuites ) {
73
73
return [ ]
74
74
}
@@ -78,38 +78,57 @@ function junitToJson(xmlData: {
78
78
: [ xmlData . testsuites . testsuite ]
79
79
80
80
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' ]
85
84
const testCases = Array . isArray ( suite . testcase ) ? suite . testcase : [ suite . testcase ]
86
85
86
+ const passed = total - failed - suite [ '@skipped' ]
87
87
const testSuite : TestSuite = {
88
88
name,
89
89
file : testCases [ 0 ] ?. [ '@file' ] ,
90
90
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.
92
95
skipped : 0 ,
93
- total : tests ,
96
+ total,
94
97
testCases : [ ] ,
98
+ // This is computed below by detecting duplicates
99
+ retries : 0 ,
95
100
}
96
- const skippedTestsForFile = testConfig . skipped . find (
101
+ const skipConfigForFile = testConfig . skipped . find (
97
102
( skippedTest ) => skippedTest . file === testSuite . file ,
98
103
)
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
+ } ) ?? [ ]
99
113
100
114
// 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
103
116
104
117
for ( const testCase of testCases ) {
118
+ // Omit tests skipped in the Next.js repo itself
105
119
if ( 'skipped' in testCase ) {
106
120
continue
107
121
}
122
+ // Omit tests we've marked as "skipped" in `test-config.json`
123
+ if ( skippedTestsForFile ?. some ( ( { name } ) => name === testCase [ '@name' ] ) ) {
124
+ continue
125
+ }
108
126
const status = testCase . failure ? 'failed' : 'passed'
109
127
testCount [ status ] ++
110
128
const test : TestCase = {
111
129
name : testCase [ '@name' ] ,
112
130
status,
131
+ retries : 0 ,
113
132
}
114
133
if ( status === 'failed' ) {
115
134
const failure = testConfig . failures . find (
@@ -123,36 +142,74 @@ function junitToJson(xmlData: {
123
142
testSuite . testCases . push ( test )
124
143
}
125
144
126
- if ( skippedTestsForFile ?. tests ) {
127
- testCount . skipped += skippedTestsForFile . tests . length
145
+ if ( ! isEntireSuiteSkipped && skippedTestsForFile . length > 0 ) {
146
+ testCount . skipped += skippedTestsForFile . length
128
147
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 => ( {
138
150
name : test . name ,
139
151
status : 'skipped' ,
140
152
reason : test . reason ,
141
- }
142
- } ) ,
153
+ retries : 0 ,
154
+ } ) ,
155
+ ) ,
143
156
)
144
- } else if ( skippedTestsForFile != null ) {
145
- // If `tests` is omitted, all tests in the file are skipped
157
+ } else if ( isEntireSuiteSkipped ) {
146
158
testCount . skipped += testSuite . total
147
159
}
148
160
return testSuite
149
161
} )
150
162
}
151
163
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
+
152
209
async function processJUnitFiles (
153
210
directoryPath : string ,
154
211
) : Promise < Array < TestSuite | SkippedTestSuite > > {
155
- const results : ( TestSuite | SkippedTestSuite ) [ ] = [ ]
212
+ const results : TestSuite [ ] = [ ]
156
213
for await ( const file of expandGlob ( `${ directoryPath } /**/*.xml` ) ) {
157
214
const xmlData = await parseXMLFile ( file . path )
158
215
results . push ( ...junitToJson ( xmlData ) )
@@ -170,9 +227,8 @@ async function processJUnitFiles(
170
227
skipped : true ,
171
228
} ) ,
172
229
)
173
- results . push ( ...skippedSuites )
174
230
175
- return results
231
+ return [ ... dedupeTestResults ( results ) , ... skippedSuites ]
176
232
}
177
233
178
234
// Get the directory path from the command-line arguments
0 commit comments