11#!/usr/bin/env node
22/**
3- * generate-test-city-3d.js
4- * Creates an interactive 3D city visualization from actual Playwright test results
5- * Each building represents a test, grouped by test suite
3+ * generate-test-city-3d.js (patched 2025‑08‑02)
4+ * Creates an interactive 3D city visualization from Playwright test results.
5+ * Each building represents a test, grouped by test suite.
66 */
77
8- const fs = require ( 'fs' ) ;
8+ /* eslint-disable no-console */
9+
10+ const fs = require ( 'fs' ) ;
911const path = require ( 'path' ) ;
1012
11- const ART = 'artifacts' ;
13+ // ────────────────────────────────────────────────────────────
14+ // Constants / helpers
15+ // ────────────────────────────────────────────────────────────
16+ const ART = 'artifacts' ; // dashboard output folder
17+ const HISTORY_FILE = path . join ( ART , 'test-history-insights.json' ) ; // output from track-test-history.js
1218
13- // Helper to read JSON safely
14- const readJSON = ( filepath , defaultValue = null ) => {
19+ // Safe JSON reader
20+ function readJSON ( filepath , def = null ) {
1521 try {
1622 return JSON . parse ( fs . readFileSync ( filepath , 'utf8' ) ) ;
17- } catch ( e ) {
18- console . warn ( `Could not read ${ filepath } :` , e . message ) ;
19- return defaultValue ;
23+ } catch ( err ) {
24+ if ( err . code !== 'ENOENT' ) {
25+ console . warn ( `⚠️ Could not parse ${ filepath } :` , err . message ) ;
26+ }
27+ return def ;
2028 }
21- } ;
29+ }
2230
23- // Extract comprehensive test data from Playwright metrics
24- function extractTestData ( ) {
25- const possiblePaths = [
31+ // ────────────────────────────────────────────────────────────
32+ // Extract all test data we have (metrics + history)
33+ // ────────────────────────────────────────────────────────────
34+ function extractTestData ( ) {
35+ // Prefer the rich metrics file emitted by Playwright reporter
36+ const candidatePaths = [
2637 'playwright-metrics.json' ,
2738 path . join ( ART , 'playwright-metrics.json' ) ,
28- path . join ( ART , 'playwright-summary-pr.json' ) ,
29- path . join ( ART , 'playwright-summary.json' )
39+ path . join ( ART , 'playwright-summary-pr.json' ) , // minimal
40+ path . join ( ART , 'playwright-summary.json' ) // alias
3041 ] ;
31-
42+
3243 let metrics = null ;
33- for ( const p of possiblePaths ) {
44+ for ( const p of candidatePaths ) {
3445 if ( fs . existsSync ( p ) ) {
3546 metrics = readJSON ( p ) ;
3647 if ( metrics && ( metrics . suites || metrics . total ) ) {
@@ -39,157 +50,116 @@ function extractTestData() {
3950 }
4051 }
4152 }
42-
43- // If no detailed metrics found, try to use summary
53+
54+ // Fall‑back: if we only have a summary, return one pseudo‑suite so the UI still works
4455 if ( ! metrics || ! metrics . suites ) {
4556 const summary = readJSON ( path . join ( ART , 'playwright-summary-pr.json' ) ) ;
4657 if ( summary && summary . total ) {
47- // Create a minimal structure from summary
4858 console . log ( 'Using summary data for visualization' ) ;
4959 return [ {
50- id : 'summary-tests' ,
51- suite : 'All Tests' ,
52- describe : 'Summary' ,
53- name : `${ summary . passed } passed, ${ summary . failed } failed` ,
54- duration : summary . duration || 0 ,
55- passRate : summary . pass_rate || 0 ,
56- passed : summary . passed || 0 ,
57- failed : summary . failed || 0 ,
58- total : summary . total || 0 ,
59- lastStatus : summary . failed > 0 ? 'failed' : 'passed' ,
60- priority : 1
60+ id : 'summary-tests' ,
61+ suite : 'All Tests' ,
62+ describe : 'Summary' ,
63+ name : `${ summary . passed } passed, ${ summary . failed } failed` ,
64+ duration : summary . duration || 0 ,
65+ passRate : summary . pass_rate || 0 ,
66+ passed : summary . passed || 0 ,
67+ failed : summary . failed || 0 ,
68+ total : summary . total || 0 ,
69+ lastStatus : summary . failed > 0 ? 'failed' : 'passed' ,
70+ priority : 1
6171 } ] ;
6272 }
73+ // absolutely nothing to show
74+ return [ ] ;
6375 }
64-
76+
77+ // Load history if it exists (for flakiness calc)
78+ const historyData = readJSON ( HISTORY_FILE , { tests : { } } ) ;
79+ const historyMap = historyData . tests || { } ;
80+
6581 const testData = [ ] ;
66- const testHistoryMap = { } ;
67-
68- // Build history map for flakiness calculation
69- if ( history && history . tests ) {
70- Object . entries ( history . tests ) . forEach ( ( [ testName , data ] ) => {
71- testHistoryMap [ testName ] = data ;
72- } ) ;
73- }
74-
75- // Process each test suite
76- metrics . suites . forEach ( ( suite , suiteIdx ) => {
77- const suiteName = path . basename ( suite . file || `suite-${ suiteIdx } ` )
78- . replace ( / \. ( s p e c | t e s t ) \. ( j s | t s | j s x | t s x ) $ / , '' ) ;
79-
80- // Get suite location info
81- const line = suite . line || 0 ;
82- const column = suite . column || 0 ;
83-
84- suite . suites . forEach ( ( describe , describeIdx ) => {
85- describe . specs . forEach ( ( spec , specIdx ) => {
86- const fullTestName = `${ suiteName } > ${ describe . title } > ${ spec . title } ` ;
87-
88- // Get all test attempts
89- const attempts = spec . tests || [ ] ;
90- let totalDuration = 0 ;
91- let passCount = 0 ;
92- let failCount = 0 ;
93- let skipCount = 0 ;
94- let lastStatus = 'unknown' ;
95- let errors = [ ] ;
96-
97- attempts . forEach ( test => {
98- test . results . forEach ( result => {
99- totalDuration += result . duration || 0 ;
100-
101- if ( result . status === 'passed' || result . status === 'expected' ) {
102- passCount ++ ;
103- lastStatus = 'passed' ;
104- } else if ( result . status === 'failed' || result . status === 'unexpected' ) {
105- failCount ++ ;
106- lastStatus = 'failed' ;
107- if ( result . error ) {
108- errors . push ( {
109- message : result . error . message ,
110- stack : result . error . stack
111- } ) ;
112- }
113- } else if ( result . status === 'skipped' ) {
114- skipCount ++ ;
115- lastStatus = 'skipped' ;
116- }
82+
83+ metrics . suites . forEach ( ( suite , sIdx ) => {
84+ const suiteName = path . basename ( suite . file || `suite-${ sIdx } ` )
85+ . replace ( / \. ( s p e c | t e s t ) \. ( j s x ? | t s x ? ) $ / , '' ) ;
86+
87+ suite . suites . forEach ( ( describe , dIdx ) => {
88+ describe . specs . forEach ( ( spec , tIdx ) => {
89+ const fullName = `${ suiteName } > ${ describe . title } > ${ spec . title } ` ;
90+ const attempts = spec . tests || [ ] ;
91+
92+ let durationTotal = 0 ;
93+ let passed = 0 , failed = 0 , skipped = 0 , lastStatus = 'unknown' ;
94+ const errors = [ ] ;
95+
96+ attempts . forEach ( attempt => {
97+ attempt . results . forEach ( r => {
98+ durationTotal += r . duration || 0 ;
99+ if ( r . status === 'passed' || r . status === 'expected' ) { passed ++ ; lastStatus = 'passed' ; }
100+ else if ( r . status === 'failed' || r . status === 'unexpected' ) { failed ++ ; lastStatus = 'failed' ; }
101+ else if ( r . status === 'skipped' ) { skipped ++ ; lastStatus = 'skipped' ; }
102+ if ( r . error ) errors . push ( { message : r . error . message , stack : r . error . stack } ) ;
117103 } ) ;
118104 } ) ;
119-
120- const totalRuns = passCount + failCount + skipCount ;
121- const avgDuration = totalRuns > 0 ? totalDuration / totalRuns : 0 ;
122- const passRate = totalRuns > 0 ? ( passCount / totalRuns ) * 100 : 0 ;
123-
124- // Calculate flakiness from history or current runs
105+
106+ const runs = passed + failed + skipped ;
107+ const avgDur = runs ? durationTotal / runs : 0 ;
108+ const passRate = runs ? ( passed / runs ) * 100 : 0 ;
109+
110+ // flakiness: prefer history, else derive from current attempts
125111 let flakiness = 0 ;
126- const historyData = testHistoryMap [ fullTestName ] ;
127- if ( historyData ) {
128- flakiness = historyData . flakiness || 0 ;
129- } else if ( totalRuns > 1 && passCount > 0 && failCount > 0 ) {
130- // Test both passed and failed in current run = flaky
131- flakiness = 50 ;
112+ if ( historyMap [ fullName ] ) {
113+ flakiness = historyMap [ fullName ] . flakiness || 0 ;
114+ } else if ( runs > 1 && passed > 0 && failed > 0 ) {
115+ flakiness = 50 ; // simplistic
132116 }
133-
134- // Determine test category
117+
118+ // tag category
135119 let category = 'standard' ;
136- if ( spec . title . toLowerCase ( ) . includes ( 'critical' ) ||
137- describe . title . toLowerCase ( ) . includes ( 'critical' ) ) {
138- category = 'critical' ;
139- } else if ( spec . title . toLowerCase ( ) . includes ( 'smoke' ) ||
140- describe . title . toLowerCase ( ) . includes ( 'smoke' ) ) {
141- category = 'smoke' ;
142- } else if ( spec . title . toLowerCase ( ) . includes ( 'regression' ) ) {
143- category = 'regression' ;
144- }
145-
120+ const loTitle = spec . title . toLowerCase ( ) ;
121+ const loDesc = describe . title . toLowerCase ( ) ;
122+ if ( loTitle . includes ( 'critical' ) || loDesc . includes ( 'critical' ) ) category = 'critical' ;
123+ else if ( loTitle . includes ( 'smoke' ) || loDesc . includes ( 'smoke' ) ) category = 'smoke' ;
124+ else if ( loTitle . includes ( 'regression' ) ) category = 'regression' ;
125+
146126 testData . push ( {
147- id : `${ suiteName } -${ describeIdx } -${ specIdx } ` ,
127+ id : `${ suiteName } -${ dIdx } -${ tIdx } ` ,
148128 suite : suiteName ,
149129 describe : describe . title ,
150130 name : spec . title ,
151- fullName : fullTestName ,
152- duration : avgDuration ,
153- totalDuration : totalDuration ,
154- passRate : passRate ,
155- runs : totalRuns ,
156- passed : passCount ,
157- failed : failCount ,
158- skipped : skipCount ,
159- lastStatus : lastStatus ,
160- flakiness : flakiness ,
161- category : category ,
162- line : spec . line || line ,
163- column : spec . column || column ,
164- errors : errors ,
165- // Calculate priority for height
166- priority : calculateTestPriority ( avgDuration , passRate , flakiness , category )
131+ fullName,
132+ duration : avgDur ,
133+ totalDuration : durationTotal ,
134+ passRate,
135+ runs,
136+ passed,
137+ failed,
138+ skipped,
139+ lastStatus,
140+ flakiness,
141+ category,
142+ line : spec . line || suite . line || 0 ,
143+ column : spec . column || suite . column || 0 ,
144+ errors,
145+ priority : calcPriority ( avgDur , passRate , flakiness , category )
167146 } ) ;
168147 } ) ;
169148 } ) ;
170149 } ) ;
171-
150+
172151 return testData ;
173152}
174153
175- // Calculate test priority (affects building height)
176- function calculateTestPriority ( duration , passRate , flakiness , category ) {
177- let priority = 1 ;
178-
179- // Category weight
180- if ( category === 'critical' ) priority *= 2 ;
181- else if ( category === 'smoke' ) priority *= 1.5 ;
182-
183- // Duration weight (longer = higher priority)
184- priority *= ( 1 + duration / 5000 ) ;
185-
186- // Failure weight (lower pass rate = higher priority)
187- priority *= ( 2 - passRate / 100 ) ;
188-
189- // Flakiness weight
190- if ( flakiness > 30 ) priority *= 1.5 ;
191-
192- return priority ;
154+ function calcPriority ( duration , passRate , flakiness , category ) {
155+ let p = 1 ;
156+ if ( category === 'critical' ) p *= 2 ;
157+ else if ( category === 'smoke' ) p *= 1.5 ;
158+
159+ p *= 1 + duration / 5_000 ; // slower → taller
160+ p *= 2 - passRate / 100 ; // failing → taller
161+ if ( flakiness > 30 ) p *= 1.5 ; // flaky → taller
162+ return p ;
193163}
194164
195165// Generate the 3D city HTML
@@ -858,40 +828,30 @@ init();
858828}
859829
860830// Main execution
861- console . log ( '🏙️ Generating 3D Test City visualization...' ) ;
862-
831+ console . log ( '🏙️ Generating 3D Test City visualization…' ) ;
863832const testData = extractTestData ( ) ;
864- if ( testData . length === 0 ) {
865- console . error ( '❌ No test data found to visualize ' ) ;
833+ if ( ! testData . length ) {
834+ console . error ( '❌ No test data found — nothing to visualise. ' ) ;
866835 process . exit ( 1 ) ;
867836}
837+ console . log ( `📊 Visualising ${ testData . length } tests` ) ;
868838
869- console . log ( `📊 Found ${ testData . length } tests to visualize` ) ;
870-
871- // Generate HTML
839+ // Generate HTML (using original giant template function)
872840const html = generate3DCityHTML ( testData ) ;
873841
874- // Save files
842+ // Ensure output dirs
875843fs . mkdirSync ( path . join ( ART , 'web-report' ) , { recursive : true } ) ;
844+
876845fs . writeFileSync ( path . join ( ART , 'web-report' , 'test-city-3d.html' ) , html ) ;
846+ fs . writeFileSync ( path . join ( ART , 'test-city-data.json' ) , JSON . stringify ( {
847+ generated : new Date ( ) . toISOString ( ) ,
848+ stats : {
849+ total : testData . length ,
850+ passed : testData . filter ( t => t . lastStatus === 'passed' ) . length ,
851+ failed : testData . filter ( t => t . lastStatus === 'failed' ) . length ,
852+ flaky : testData . filter ( t => t . flakiness > 30 ) . length
853+ } ,
854+ tests : testData
855+ } , null , 2 ) ) ;
877856
878- // Also save the processed test data for other tools
879- fs . writeFileSync (
880- path . join ( ART , 'test-city-data.json' ) ,
881- JSON . stringify ( {
882- generated : new Date ( ) . toISOString ( ) ,
883- stats : {
884- total : testData . length ,
885- passed : testData . filter ( t => t . lastStatus === 'passed' ) . length ,
886- failed : testData . filter ( t => t . lastStatus === 'failed' ) . length ,
887- flaky : testData . filter ( t => t . flakiness > 30 ) . length
888- } ,
889- tests : testData
890- } , null , 2 )
891- ) ;
892-
893- console . log ( '✅ Test City 3D visualization generated' ) ;
894- console . log ( '📍 Location: artifacts/web-report/test-city-3d.html' ) ;
895- console . log ( '🏢 Building colors: Green (passing) → Orange (unstable) → Red (failing)' ) ;
896- console . log ( '✨ Glowing buildings indicate flaky tests' ) ;
897- console . log ( '⭐ Stars indicate critical tests' ) ;
857+ console . log ( '✅ 3D Test City generated → artifacts/web-report/test-city-3d.html' ) ;
0 commit comments