1
1
import * as fs from 'fs/promises' ;
2
- import humanizeDuration from 'humanize-duration' ;
3
2
import path from 'path' ;
4
- import * as xml2js from 'xml2js' ;
5
-
6
- const XML = {
7
- parse : new xml2js . Parser ( ) . parseStringPromise ,
8
- } ;
9
-
10
- const humanizer = humanizeDuration . humanizer ( {
11
- language : 'shortEn' ,
12
- languages : {
13
- shortEn : {
14
- y : ( ) => 'y' ,
15
- mo : ( ) => 'mo' ,
16
- w : ( ) => 'w' ,
17
- d : ( ) => 'd' ,
18
- h : ( ) => 'h' ,
19
- m : ( ) => 'm' ,
20
- s : ( ) => 's' ,
21
- ms : ( ) => 'ms' ,
22
- } ,
23
- } ,
24
- delimiter : ' ' ,
25
- spacer : '' ,
26
- round : true ,
27
- } ) ;
28
-
29
- function formatTime ( ms : number ) : string {
30
- if ( ms < 1000 ) {
31
- return `${ Math . round ( ms ) } ms` ;
32
- }
33
- return humanizer ( ms ) ;
34
- }
35
-
36
- /**
37
- * Replaces HTML `<strong>` tags with ANSI escape codes to format
38
- * text as bold in the console output.
39
- */
40
- function consoleBold ( str : string ) : string {
41
- return str
42
- . replaceAll ( '<strong>' , '\x1b[1m' )
43
- . replaceAll ( '</strong>' , '\x1b[0m' ) ;
44
- }
45
-
46
- interface TestRun {
47
- name : string ;
48
- testSuites : TestSuite [ ] ;
49
- }
50
-
51
- interface TestSuite {
52
- name : string ;
53
- job : {
54
- name : string ;
55
- id : string ;
56
- } ;
57
- path : string ;
58
- date : Date ;
59
- tests : number ;
60
- passed : number ;
61
- failed : number ;
62
- skipped : number ;
63
- time : number ;
64
- testCases : TestCase [ ] ;
65
- }
66
-
67
- type TestCase =
68
- | {
69
- name : string ;
70
- time : number ;
71
- status : 'passed' ;
72
- }
73
- | {
74
- name : string ;
75
- time : number ;
76
- status : 'failed' ;
77
- error : string ;
78
- } ;
3
+ import type {
4
+ TestCase ,
5
+ TestFile ,
6
+ TestRun ,
7
+ TestSuite ,
8
+ } from './shared/test-reports' ;
9
+ import {
10
+ consoleBold ,
11
+ formatTime ,
12
+ normalizeTestPath ,
13
+ XML ,
14
+ } from './shared/utils' ;
79
15
80
16
async function main ( ) {
81
17
const env = {
@@ -115,15 +51,16 @@ async function main() {
115
51
'utf8' ,
116
52
) ;
117
53
const results = await XML . parse ( file ) ;
54
+
118
55
for ( const suite of results . testsuites . testsuite || [ ] ) {
119
56
if ( ! suite . testcase || ! suite . $ . file ) continue ;
120
57
const tests = + suite . $ . tests ;
121
58
const failed = + suite . $ . failures ;
122
59
const skipped = tests - suite . testcase . length ;
123
60
const passed = tests - failed - skipped ;
61
+
124
62
const testSuite : TestSuite = {
125
63
name : suite . $ . name ,
126
- path : suite . $ . file . slice ( suite . $ . file . indexOf ( `test${ path . sep } ` ) ) ,
127
64
job : {
128
65
name : suite . properties ?. [ 0 ] . property ?. [ 0 ] ?. $ . value ?? '' ,
129
66
id : suite . properties ?. [ 0 ] . property ?. [ 1 ] ?. $ . value ?? '' ,
@@ -134,8 +71,10 @@ async function main() {
134
71
failed,
135
72
skipped,
136
73
time : + suite . $ . time * 1000 , // convert to ms,
74
+ attempts : [ ] ,
137
75
testCases : [ ] ,
138
76
} ;
77
+
139
78
for ( const test of suite . testcase || [ ] ) {
140
79
const testCase : TestCase = {
141
80
name : test . $ . name ,
@@ -145,63 +84,125 @@ async function main() {
145
84
} ;
146
85
testSuite . testCases . push ( testCase ) ;
147
86
}
87
+
88
+ const testFile : TestFile = {
89
+ path : normalizeTestPath ( suite . $ . file ) ,
90
+ tests : testSuite . tests ,
91
+ passed : testSuite . passed ,
92
+ failed : testSuite . failed ,
93
+ skipped : testSuite . skipped ,
94
+ time : testSuite . time ,
95
+ testSuites : [ testSuite ] ,
96
+ } ;
97
+
148
98
const testRun : TestRun = {
149
99
// regex to remove the shard number from the job name
150
100
name : testSuite . job . name . replace ( / \s + \( \d + \) $ / , '' ) ,
151
- testSuites : [ testSuite ] ,
101
+ testFiles : [ testFile ] ,
152
102
} ;
103
+
153
104
const existingRun = testRuns . find ( ( run ) => run . name === testRun . name ) ;
154
105
if ( existingRun ) {
155
- existingRun . testSuites . push ( testSuite ) ;
106
+ const existingFile = existingRun . testFiles . find (
107
+ ( file ) => file . path === testFile . path ,
108
+ ) ;
109
+ if ( existingFile ) {
110
+ existingFile . testSuites . push ( testSuite ) ;
111
+ } else {
112
+ existingRun . testFiles . push ( testFile ) ;
113
+ }
156
114
} else {
157
115
testRuns . push ( testRun ) ;
158
116
}
159
117
}
160
118
}
161
119
162
120
for ( const testRun of testRuns ) {
163
- const deduped : { [ path : string ] : TestSuite } = { } ;
164
- for ( const suite of testRun . testSuites ) {
165
- const existing = deduped [ suite . path ] ;
166
- // If there is a duplicate, we keep the suite with the latest date
167
- if ( ! existing || existing . date < suite . date ) {
168
- deduped [ suite . path ] = suite ;
121
+ for ( const testFile of testRun . testFiles ) {
122
+ // Group test suites by name
123
+ const suitesByName : Record < string , TestSuite [ ] > = { } ;
124
+
125
+ for ( const suite of testFile . testSuites ) {
126
+ if ( ! suitesByName [ suite . name ] ) {
127
+ suitesByName [ suite . name ] = [ ] ;
128
+ }
129
+ suitesByName [ suite . name ] . push ( suite ) ;
169
130
}
170
- }
171
131
172
- const suites = Object . values ( deduped ) ;
132
+ // Determine the latest test suite by date and nest attempts
133
+ const attempts : TestSuite [ ] [ ] = [ ] ;
134
+ for ( const suites of Object . values ( suitesByName ) ) {
135
+ suites . sort ( ( a , b ) => b . date . getTime ( ) - a . date . getTime ( ) ) ; // sort newest first
136
+ const [ latest , ...otherAttempts ] = suites ;
137
+ latest . attempts = otherAttempts ;
138
+ attempts . push ( otherAttempts ) ;
139
+ }
173
140
174
- const title = `<strong>${ testRun . name } </strong>` ;
175
- console . log ( consoleBold ( title ) ) ;
141
+ // Remove the nested attempts from the top-level list
142
+ const attemptSet = new Set ( attempts . flat ( ) ) ;
143
+ testFile . testSuites = testFile . testSuites . filter (
144
+ ( suite ) => ! attemptSet . has ( suite ) ,
145
+ ) ;
176
146
177
- if ( suites . length ) {
178
- const total = suites . reduce (
147
+ const total = testFile . testSuites . reduce (
179
148
( acc , suite ) => ( {
180
149
tests : acc . tests + suite . tests ,
181
150
passed : acc . passed + suite . passed ,
182
151
failed : acc . failed + suite . failed ,
183
152
skipped : acc . skipped + suite . skipped ,
153
+ time : acc . time + suite . time ,
184
154
} ) ,
185
- { tests : 0 , passed : 0 , failed : 0 , skipped : 0 } ,
155
+ { tests : 0 , passed : 0 , failed : 0 , skipped : 0 , time : 0 } ,
186
156
) ;
187
157
188
- core . summary . addRaw (
189
- total . failed ? `\n<details open>\n` : `\n<details>\n` ,
158
+ testFile . tests = total . tests ;
159
+ testFile . passed = total . passed ;
160
+ testFile . failed = total . failed ;
161
+ testFile . skipped = total . skipped ;
162
+ testFile . time = total . time ;
163
+ }
164
+
165
+ testRun . testFiles . sort ( ( a , b ) => a . path . localeCompare ( b . path ) ) ;
166
+
167
+ const title = `<strong>${ testRun . name } </strong>` ;
168
+
169
+ if ( testRun . testFiles . length ) {
170
+ const total = testRun . testFiles . reduce (
171
+ ( acc , file ) => ( {
172
+ tests : acc . tests + file . tests ,
173
+ passed : acc . passed + file . passed ,
174
+ failed : acc . failed + file . failed ,
175
+ skipped : acc . skipped + file . skipped ,
176
+ time : acc . time + file . time ,
177
+ } ) ,
178
+ { tests : 0 , passed : 0 , failed : 0 , skipped : 0 , time : 0 } ,
190
179
) ;
191
- core . summary . addRaw ( `\n<summary>${ title } </summary>\n` ) ;
192
180
193
- const times = suites . map ( ( suite ) => {
194
- const start = suite . date . getTime ( ) ;
195
- const duration = suite . time ;
196
- return { start, end : start + duration } ;
197
- } ) ;
181
+ if ( total . failed > 0 ) {
182
+ if ( testRun . name ) console . log ( `${ consoleBold ( title ) } ❌` ) ;
183
+ core . summary . addRaw ( `\n<details open>\n` ) ;
184
+ core . summary . addRaw ( `\n<summary>${ title } ❌</summary>\n` ) ;
185
+ } else {
186
+ if ( testRun . name ) console . log ( `${ consoleBold ( title ) } ✅` ) ;
187
+ core . summary . addRaw ( `\n<details>\n` ) ;
188
+ core . summary . addRaw ( `\n<summary>${ title } ✅</summary>\n` ) ;
189
+ }
190
+
191
+ const times = testRun . testFiles
192
+ . map ( ( file ) =>
193
+ file . testSuites . map ( ( suite ) => ( {
194
+ start : suite . date . getTime ( ) ,
195
+ end : suite . date . getTime ( ) + suite . time ,
196
+ } ) ) ,
197
+ )
198
+ . flat ( ) ;
198
199
const earliestStart = Math . min ( ...times . map ( ( t ) => t . start ) ) ;
199
200
const latestEnd = Math . max ( ...times . map ( ( t ) => t . end ) ) ;
200
201
const executionTime = latestEnd - earliestStart ;
201
202
202
- const conclusion = `<strong>${
203
- total . tests
204
- } </strong> tests were completed in <strong>${ formatTime (
203
+ const conclusion = `<strong>${ total . tests } </strong> ${
204
+ total . tests === 1 ? 'test was' : 'tests were'
205
+ } completed in <strong>${ formatTime (
205
206
executionTime ,
206
207
) } </strong> with <strong>${ total . passed } </strong> passed, <strong>${
207
208
total . failed
@@ -216,37 +217,40 @@ async function main() {
216
217
core . summary . addRaw (
217
218
`\n<hr style="height: 1px; margin-top: -5px; margin-bottom: 10px;">\n` ,
218
219
) ;
219
- for ( const suite of suites ) {
220
- if ( ! suite . failed ) continue ;
221
- console . error ( suite . path ) ;
220
+ for ( const file of testRun . testFiles ) {
221
+ if ( file . failed === 0 ) continue ;
222
+ console . error ( file . path ) ;
222
223
core . summary . addRaw (
223
- `\n#### [${ suite . path } ](https://github.com/${ env . OWNER } /${ env . REPOSITORY } /blob/${ env . BRANCH } /${ suite . path } )\n` ,
224
+ `\n#### [${ file . path } ](https://github.com/${ env . OWNER } /${ env . REPOSITORY } /blob/${ env . BRANCH } /${ file . path } )\n` ,
224
225
) ;
225
- if ( suite . job . name && suite . job . id && env . RUN_ID ) {
226
- core . summary . addRaw (
227
- `\n##### Job: [${ suite . job . name } ](https://github.com/${
228
- env . OWNER
229
- } /${ env . REPOSITORY } /actions/runs/${ env . RUN_ID } /job/${
230
- suite . job . id
231
- } ${ env . PR_NUMBER ? `?pr=${ env . PR_NUMBER } ` : '' } )\n`,
232
- ) ;
233
- }
234
- for ( const test of suite . testCases ) {
235
- if ( test . status !== 'failed' ) continue ;
236
- console . error ( ` ${ test . name } ` ) ;
237
- console . error ( ` ${ test . error } \n` ) ;
238
- core . summary . addRaw ( `\n##### ${ test . name } \n` ) ;
239
- core . summary . addRaw ( `\n\`\`\`js\n${ test . error } \n\`\`\`\n` ) ;
226
+ for ( const suite of file . testSuites ) {
227
+ if ( suite . failed === 0 ) continue ;
228
+ if ( suite . job . name && suite . job . id && env . RUN_ID ) {
229
+ core . summary . addRaw (
230
+ `\n##### Job: [${ suite . job . name } ](https://github.com/${
231
+ env . OWNER
232
+ } /${ env . REPOSITORY } /actions/runs/${ env . RUN_ID } /job/${
233
+ suite . job . id
234
+ } ${ env . PR_NUMBER ? `?pr=${ env . PR_NUMBER } ` : '' } )\n`,
235
+ ) ;
236
+ }
237
+ for ( const test of suite . testCases ) {
238
+ if ( test . status !== 'failed' ) continue ;
239
+ console . error ( ` ${ test . name } ` ) ;
240
+ console . error ( ` ${ test . error } \n` ) ;
241
+ core . summary . addRaw ( `\n##### ${ test . name } \n` ) ;
242
+ core . summary . addRaw ( `\n\`\`\`js\n${ test . error } \n\`\`\`\n` ) ;
243
+ }
240
244
}
241
245
}
242
246
}
243
247
244
- const rows = suites . map ( ( suite ) => ( {
245
- 'Test suite ' : suite . path ,
246
- Passed : suite . passed ? `${ suite . passed } ✅` : '' ,
247
- Failed : suite . failed ? `${ suite . failed } ❌` : '' ,
248
- Skipped : suite . skipped ? `${ suite . skipped } ⏩` : '' ,
249
- Time : formatTime ( suite . time ) ,
248
+ const rows = testRun . testFiles . map ( ( file ) => ( {
249
+ 'Test file ' : file . path ,
250
+ Passed : file . passed ? `${ file . passed } ✅` : '' ,
251
+ Failed : file . failed ? `${ file . failed } ❌` : '' ,
252
+ Skipped : file . skipped ? `${ file . skipped } ⏩` : '' ,
253
+ Time : formatTime ( file . time ) ,
250
254
} ) ) ;
251
255
252
256
const columns = Object . keys ( rows [ 0 ] ) ;
@@ -256,7 +260,7 @@ async function main() {
256
260
. map ( ( row ) => {
257
261
const data = {
258
262
...row ,
259
- 'Test suite ' : `[${ row [ 'Test suite ' ] } ](https://github.com/${ env . OWNER } /${ env . REPOSITORY } /blob/${ env . BRANCH } /${ row [ 'Test suite ' ] } )` ,
263
+ 'Test file ' : `[${ row [ 'Test file ' ] } ](https://github.com/${ env . OWNER } /${ env . REPOSITORY } /blob/${ env . BRANCH } /${ row [ 'Test file ' ] } )` ,
260
264
} ;
261
265
return `| ${ Object . values ( data ) . join ( ' | ' ) } |` ;
262
266
} )
0 commit comments