33 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
55import path from 'node:path' ;
6+ import { existsSync , readFileSync } from 'fs' ;
67import {
78 FullConfig ,
89 FullResult ,
@@ -13,15 +14,21 @@ import {
1314 TestResult ,
1415} from '@playwright/test/reporter' ;
1516
17+ const FXTRACE_BASE_URL = 'https://fxtrace.vercel.app' ;
18+ const CIRCLECI_ARTIFACTS_BASE_URL = 'https://output.circle-artifacts.com/output/job' ;
19+
20+ const STATUS_ICONS : Record < string , string > = {
21+ passed : '✅' ,
22+ skipped : '↩️' ,
23+ timedOut : '⌛️' ,
24+ failed : '❌' ,
25+ interrupted : '❌' ,
26+ } ;
27+
1628/**
17- * Converts milliseconds to human-readable format
18- * If the time is less than 1 second, it shows milliseconds
19- * If the time is less than 1 minute, it shows seconds
20- * And if over 1 minute, it shows minutes and seconds
21- * @param ms
29+ * Converts milliseconds to human-readable format (e.g., "5s", "2m30s")
2230 */
23- const formatTime = ( ms : number ) => {
24- // protect against bad input so we don't crash the reporter
31+ const formatTime = ( ms : number ) : string => {
2532 if ( ms === undefined || ms === null || isNaN ( ms ) || ms < 0 ) {
2633 return 'unknown' ;
2734 }
@@ -36,62 +43,185 @@ const formatTime = (ms: number) => {
3643 return `${ minutes } m${ seconds % 60 } s` ;
3744} ;
3845
46+ /**
47+ * Walks up the directory tree looking for a package.json with `"name": "fxa"`.
48+ */
49+ function findRootPackageJson ( startDir : string = __dirname ) : string {
50+ let currentDir = startDir ;
51+ let parentDir = '' ;
52+
53+ while ( currentDir !== parentDir ) {
54+ const packageJsonPath = path . join ( currentDir , 'package.json' ) ;
55+
56+ if ( existsSync ( packageJsonPath ) ) {
57+ try {
58+ const json = JSON . parse ( readFileSync ( packageJsonPath , 'utf-8' ) ) ;
59+ if ( json . name === 'fxa' ) {
60+ return currentDir ;
61+ }
62+ } catch {
63+ // Invalid JSON, continue searching
64+ }
65+ }
66+
67+ parentDir = currentDir ;
68+ currentDir = path . dirname ( currentDir ) ;
69+ }
70+
71+ throw new Error ( 'Could not find root package.json' ) ;
72+ }
73+
74+ /**
75+ * Generates an fxtrace URL for a trace file in CircleCI
76+ */
77+ function generateTraceUrl ( tracePath : string ) : string | null {
78+ if ( ! process . env . CI || ! process . env . CIRCLECI ) {
79+ return null ;
80+ }
81+
82+ const workflowJobId = process . env . CIRCLE_WORKFLOW_JOB_ID ;
83+ const nodeIndex = process . env . CIRCLE_NODE_INDEX || '0' ;
84+
85+ if ( ! workflowJobId ) {
86+ return null ;
87+ }
88+
89+ const projectRoot = findRootPackageJson ( ) ;
90+ const absolutePath = path . resolve ( process . cwd ( ) , tracePath ) ;
91+ const relativePath = path
92+ . relative ( projectRoot , absolutePath )
93+ . replace ( / \\ / g, '/' ) ;
94+
95+ const artifactUrl = `${ CIRCLECI_ARTIFACTS_BASE_URL } /${ workflowJobId } /artifacts/${ nodeIndex } /${ relativePath } ` ;
96+ return `${ FXTRACE_BASE_URL } /?url=${ artifactUrl } ` ;
97+ }
98+
99+ interface FailedTestTrace {
100+ testTitle : string ;
101+ testFile : string ;
102+ traceUrls : string [ ] ;
103+ }
104+
39105class CIReporter implements Reporter {
40106 private fixmeCount = 0 ;
41107 private passCount = 0 ;
42108 private skipCount = 0 ;
43109 private total = 0 ;
110+ private completedCount = 0 ;
111+ private retryCount = 0 ;
112+ private flakyTests = new Set < string > ( ) ;
113+ private failedTestTraces = new Map < string , FailedTestTrace > ( ) ;
44114
45115 onBegin ( config : FullConfig , suite : Suite ) {
46116 this . total = suite . allTests ( ) . length ;
47117 console . log ( `Running ${ this . total } tests using ${ config . workers } workers` ) ;
48118 }
49119
50120 onTestEnd ( test : TestCase , result : TestResult ) {
51- let status = '❌' ;
52- switch ( result . status ) {
53- case 'passed' :
54- status = '✅' ;
55- this . passCount ++ ;
56- break ;
57- case 'skipped' :
58- status = '↩️' ;
59- this . skipCount ++ ;
60- if ( test . annotations . some ( ( a ) => a . type === 'fixme' ) ) {
61- this . fixmeCount ++ ;
62- }
63- break ;
64- case 'timedOut' :
65- status = '⌛️' ;
66- break ;
67- default :
68- break ;
121+ const testFile = path . relative ( process . cwd ( ) , test . location . file ) ;
122+ const testKey = `${ testFile } :${ test . title } ` ;
123+ const isRetry = result . retry > 0 ;
124+
125+ if ( isRetry ) {
126+ this . retryCount ++ ;
127+ } else {
128+ this . completedCount ++ ;
129+ }
130+
131+ const status = STATUS_ICONS [ result . status ] || '❌' ;
132+
133+ if ( result . status === 'passed' ) {
134+ this . passCount ++ ;
135+ if ( isRetry ) {
136+ this . flakyTests . add ( testKey ) ;
137+ }
138+ } else if ( result . status === 'skipped' ) {
139+ this . skipCount ++ ;
140+ if ( test . annotations . some ( ( a ) => a . type === 'fixme' ) ) {
141+ this . fixmeCount ++ ;
142+ }
69143 }
70144
145+ const progress = `[${ this . completedCount } /${ this . total } ]` ;
146+ const retryLabel = isRetry ? ` (retry #${ result . retry } )` : '' ;
71147 console . log (
72- `${ status } ${ path . relative ( process . cwd ( ) , test . location . file ) } : ${
73- test . title
74- } (${ formatTime ( result . duration ) } )`
148+ `${ progress } ${ status } ${ testFile } : ${ test . title } ${ retryLabel } (${ formatTime ( result . duration ) } )`
75149 ) ;
150+
76151 if ( test . outcome ( ) === 'unexpected' ) {
77152 console . log ( result . error ?. stack ) ;
78153 console . log ( result . error ?. message ) ;
79154 }
155+
156+ this . collectTraceUrl ( test , result , testKey , testFile ) ;
157+ }
158+
159+ private collectTraceUrl (
160+ test : TestCase ,
161+ result : TestResult ,
162+ testKey : string ,
163+ testFile : string
164+ ) {
165+ if ( result . status === 'passed' ) return ;
166+
167+ const traceAttachment = result . attachments ?. find (
168+ ( a ) => a . name === 'trace' && a . path
169+ ) ;
170+ if ( ! traceAttachment ?. path ) return ;
171+
172+ const traceUrl = generateTraceUrl ( traceAttachment . path ) ;
173+ if ( ! traceUrl ) return ;
174+
175+ console . log ( `\n📊 View trace: ${ traceUrl } ` ) ;
176+
177+ const existing = this . failedTestTraces . get ( testKey ) ;
178+ if ( existing ) {
179+ existing . traceUrls . push ( traceUrl ) ;
180+ } else {
181+ this . failedTestTraces . set ( testKey , {
182+ testTitle : test . title ,
183+ testFile,
184+ traceUrls : [ traceUrl ] ,
185+ } ) ;
186+ }
80187 }
81188
82189 onEnd ( result : FullResult ) {
83190 const failCount = this . total - ( this . passCount + this . skipCount ) ;
84191
85192 console . log (
86- `Test suite: ${ result . status } (` +
193+ `\nTest suite: ${ result . status } (` +
87194 `Passed: ${ this . passCount } ` +
88195 `Failed: ${ failCount } ` +
89- `Skipped: ${ this . skipCount } (Fixme: ${ this . fixmeCount } ))`
196+ `Skipped: ${ this . skipCount } (Fixme: ${ this . fixmeCount } ) ` +
197+ `Retries: ${ this . retryCount } ) ` +
198+ `in ${ formatTime ( result . duration ) } `
90199 ) ;
200+
201+ if ( this . flakyTests . size > 0 ) {
202+ console . log ( `\n⚠️ Flaky tests (${ this . flakyTests . size } ):` ) ;
203+ for ( const testKey of this . flakyTests ) {
204+ console . log ( ` ${ testKey } ` ) ;
205+ }
206+ }
207+
208+ if ( this . failedTestTraces . size > 0 ) {
209+ const traces = this . failedTestTraces ;
210+ process . on ( 'exit' , ( ) => {
211+ console . log ( '\n📊 Failed test traces:' ) ;
212+ for ( const trace of traces . values ( ) ) {
213+ console . log ( ` ${ trace . testFile } : ${ trace . testTitle } ` ) ;
214+ for ( const url of trace . traceUrls ) {
215+ console . log ( ` ${ url } ` ) ;
216+ }
217+ }
218+ } ) ;
219+ }
91220 }
92221
93222 onError ( error : TestError ) {
94223 console . log ( error . message ) ;
95224 }
96225}
226+
97227export default CIReporter ;
0 commit comments