codeceptjs 3.7.6-beta.3 → 3.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,7 @@
1
+ // @ts-nocheck
2
+ // TypeScript: Import Node.js types for process, fs, path, etc.
3
+ /// <reference types="node" />
4
+
1
5
  const fs = require('fs')
2
6
  const path = require('path')
3
7
  const mkdirp = require('mkdirp')
@@ -11,7 +15,7 @@ const output = require('../output')
11
15
  const Codecept = require('../codecept')
12
16
 
13
17
  const defaultConfig = {
14
- output: global.output_dir || './output',
18
+ output: typeof global !== 'undefined' && global.output_dir ? global.output_dir : './output',
15
19
  reportFileName: 'report.html',
16
20
  includeArtifacts: true,
17
21
  showSteps: true,
@@ -60,6 +64,9 @@ const defaultConfig = {
60
64
  */
61
65
  module.exports = function (config) {
62
66
  const options = { ...defaultConfig, ...config }
67
+ /**
68
+ * TypeScript: Explicitly type reportData arrays as any[] to avoid 'never' errors
69
+ */
63
70
  let reportData = {
64
71
  stats: {},
65
72
  tests: [],
@@ -82,8 +89,8 @@ module.exports = function (config) {
82
89
 
83
90
  // Track overall test execution
84
91
  event.dispatcher.on(event.all.before, () => {
85
- reportData.startTime = new Date()
86
- output.plugin('htmlReporter', 'Starting HTML report generation...')
92
+ reportData.startTime = new Date().toISOString()
93
+ output.print('HTML Reporter: Starting HTML report generation...')
87
94
  })
88
95
 
89
96
  // Track test start to initialize steps and hooks collection
@@ -138,10 +145,30 @@ module.exports = function (config) {
138
145
  if (step.htmlReporterStartTime) {
139
146
  step.duration = Date.now() - step.htmlReporterStartTime
140
147
  }
148
+
149
+ // Serialize args immediately to preserve them through worker serialization
150
+ let serializedArgs = []
151
+ if (step.args && Array.isArray(step.args)) {
152
+ serializedArgs = step.args.map(arg => {
153
+ try {
154
+ // Try to convert to JSON-friendly format
155
+ if (typeof arg === 'string') return arg
156
+ if (typeof arg === 'number') return arg
157
+ if (typeof arg === 'boolean') return arg
158
+ if (arg === null || arg === undefined) return arg
159
+ // For objects, try to serialize them
160
+ return JSON.parse(JSON.stringify(arg))
161
+ } catch (e) {
162
+ // If serialization fails, convert to string
163
+ return String(arg)
164
+ }
165
+ })
166
+ }
167
+
141
168
  currentTestSteps.push({
142
169
  name: step.name,
143
170
  actor: step.actor,
144
- args: step.args || [],
171
+ args: serializedArgs,
145
172
  status: step.failed ? 'failed' : 'success',
146
173
  duration: step.duration || 0,
147
174
  })
@@ -156,12 +183,21 @@ module.exports = function (config) {
156
183
  if (hook.htmlReporterStartTime) {
157
184
  hook.duration = Date.now() - hook.htmlReporterStartTime
158
185
  }
186
+ // Enhanced hook info: include type, name, location, error, and context
159
187
  const hookInfo = {
160
188
  title: hook.title,
161
189
  type: hook.type || 'unknown', // before, after, beforeSuite, afterSuite
162
190
  status: hook.err ? 'failed' : 'passed',
163
191
  duration: hook.duration || 0,
164
- error: hook.err ? hook.err.message : null,
192
+ error: hook.err ? hook.err.message || hook.err.toString() : null,
193
+ location: hook.file || hook.location || (hook.ctx && hook.ctx.test && hook.ctx.test.file) || null,
194
+ context: hook.ctx
195
+ ? {
196
+ testTitle: hook.ctx.test?.title,
197
+ suiteTitle: hook.ctx.test?.parent?.title,
198
+ feature: hook.ctx.test?.parent?.feature?.name,
199
+ }
200
+ : null,
165
201
  }
166
202
  currentTestHooks.push(hookInfo)
167
203
  reportData.hooks.push(hookInfo)
@@ -185,6 +221,43 @@ module.exports = function (config) {
185
221
  })
186
222
  })
187
223
 
224
+ // Collect skipped tests
225
+ event.dispatcher.on(event.test.skipped, test => {
226
+ const testId = generateTestId(test)
227
+
228
+ // Detect if this is a BDD/Gherkin test
229
+ const suite = test.parent || test.suite || currentSuite
230
+ const isBddTest = isBddGherkinTest(test, suite)
231
+ const featureInfo = isBddTest ? getBddFeatureInfo(test, suite) : null
232
+
233
+ // Extract parent/suite title
234
+ const parentTitle = test.parent?.title || test.suite?.title || (suite && suite.title) || null
235
+ const suiteTitle = test.suite?.title || (suite && suite.title) || null
236
+
237
+ const testData = {
238
+ ...test,
239
+ id: testId,
240
+ state: 'pending', // Use 'pending' as the state for skipped tests
241
+ duration: 0,
242
+ steps: [],
243
+ hooks: [],
244
+ artifacts: [],
245
+ tags: test.tags || [],
246
+ meta: test.meta || {},
247
+ opts: test.opts || {},
248
+ notes: test.notes || [],
249
+ retryAttempts: 0,
250
+ uid: test.uid,
251
+ isBdd: isBddTest,
252
+ feature: featureInfo,
253
+ parentTitle: parentTitle,
254
+ suiteTitle: suiteTitle,
255
+ }
256
+
257
+ reportData.tests.push(testData)
258
+ output.debug(`HTML Reporter: Added skipped test - ${test.title}`)
259
+ })
260
+
188
261
  // Collect test results
189
262
  event.dispatcher.on(event.test.finished, test => {
190
263
  const testId = generateTestId(test)
@@ -211,19 +284,25 @@ module.exports = function (config) {
211
284
  // Debug logging
212
285
  output.debug(`HTML Reporter: Test finished - ${test.title}, State: ${test.state}, Retries: ${retryAttempts}`)
213
286
 
214
- // Detect if this is a BDD/Gherkin test
215
- const isBddTest = isBddGherkinTest(test, currentSuite)
287
+ // Detect if this is a BDD/Gherkin test - use test.parent directly instead of currentSuite
288
+ const suite = test.parent || test.suite || currentSuite
289
+ const isBddTest = isBddGherkinTest(test, suite)
216
290
  const steps = isBddTest ? currentBddSteps : currentTestSteps
217
- const featureInfo = isBddTest ? getBddFeatureInfo(test, currentSuite) : null
291
+ const featureInfo = isBddTest ? getBddFeatureInfo(test, suite) : null
218
292
 
219
293
  // Check if this test already exists in reportData.tests (from a previous retry)
220
294
  const existingTestIndex = reportData.tests.findIndex(t => t.id === testId)
221
- const hasFailedBefore = existingTestIndex >= 0 && reportData.tests[existingTestIndex].state === 'failed'
295
+ const hasFailedBefore = existingTestIndex >= 0 && reportData.tests[existingTestIndex] && reportData.tests[existingTestIndex].state === 'failed'
222
296
  const currentlyFailed = test.state === 'failed'
223
297
 
224
298
  // Debug artifacts collection (but don't process them yet - screenshots may not be ready)
225
299
  output.debug(`HTML Reporter: Test ${test.title} artifacts at test.finished: ${JSON.stringify(test.artifacts)}`)
226
300
 
301
+ // Extract parent/suite title before serialization (for worker mode)
302
+ // This ensures the feature name is preserved when test data is JSON stringified
303
+ const parentTitle = test.parent?.title || test.suite?.title || (suite && suite.title) || null
304
+ const suiteTitle = test.suite?.title || (suite && suite.title) || null
305
+
227
306
  const testData = {
228
307
  ...test,
229
308
  id: testId,
@@ -239,11 +318,14 @@ module.exports = function (config) {
239
318
  uid: test.uid,
240
319
  isBdd: isBddTest,
241
320
  feature: featureInfo,
321
+ // Store parent/suite titles as simple strings for worker mode serialization
322
+ parentTitle: parentTitle,
323
+ suiteTitle: suiteTitle,
242
324
  }
243
325
 
244
326
  if (existingTestIndex >= 0) {
245
327
  // Update existing test with final result (including failed state)
246
- reportData.tests[existingTestIndex] = testData
328
+ if (existingTestIndex >= 0) reportData.tests[existingTestIndex] = testData
247
329
  output.debug(`HTML Reporter: Updated existing test - ${test.title}, Final state: ${test.state}`)
248
330
  } else {
249
331
  // Add new test
@@ -288,14 +370,14 @@ module.exports = function (config) {
288
370
  finalState: test.state,
289
371
  duration: test.duration || 0,
290
372
  })
291
- output.debug(`HTML Reporter: Fallback retry detection for failed test ${test.title}, attempts: ${fallbackAttempts}`)
373
+ output.debug(`HTML Reporter: Fallback retry detection for failed test ${test.title}, attempts: ${fallbackAttempts}`)
292
374
  }
293
375
  })
294
376
 
295
377
  // Generate final report
296
- event.dispatcher.on(event.all.result, result => {
297
- reportData.endTime = new Date()
298
- reportData.duration = reportData.endTime - reportData.startTime
378
+ event.dispatcher.on(event.all.result, async result => {
379
+ reportData.endTime = new Date().toISOString()
380
+ reportData.duration = new Date(reportData.endTime).getTime() - new Date(reportData.startTime).getTime()
299
381
 
300
382
  // Process artifacts now that all async tasks (including screenshots) are complete
301
383
  output.debug(`HTML Reporter: Processing artifacts for ${reportData.tests.length} tests after all async tasks complete`)
@@ -337,15 +419,25 @@ module.exports = function (config) {
337
419
  // Calculate stats from our collected test data instead of using result.stats
338
420
  const passedTests = reportData.tests.filter(t => t.state === 'passed').length
339
421
  const failedTests = reportData.tests.filter(t => t.state === 'failed').length
340
- const pendingTests = reportData.tests.filter(t => t.state === 'pending').length
341
- const skippedTests = reportData.tests.filter(t => t.state === 'skipped').length
422
+ // Combine pending and skipped tests (both represent tests that were not run)
423
+ const pendingTests = reportData.tests.filter(t => t.state === 'pending' || t.state === 'skipped').length
424
+
425
+ // Calculate flaky tests (passed but had retries)
426
+ const flakyTests = reportData.tests.filter(t => t.state === 'passed' && t.retryAttempts > 0).length
427
+
428
+ // Count total artifacts
429
+ const totalArtifacts = reportData.tests.reduce((sum, t) => sum + (t.artifacts?.length || 0), 0)
342
430
 
343
431
  // Populate failures from our collected test data with enhanced details
344
432
  reportData.failures = reportData.tests
345
433
  .filter(t => t.state === 'failed')
346
434
  .map(t => {
347
435
  const testName = t.title || 'Unknown Test'
348
- const featureName = t.parent?.title || 'Unknown Feature'
436
+ // Try to get feature name from BDD, preserved titles (worker mode), or direct access
437
+ let featureName = t.feature?.name || t.parentTitle || t.suiteTitle || t.parent?.title || t.suite?.title || 'Unknown Feature'
438
+ if (featureName === 'Unknown Feature' && t.suite && t.suite.feature && t.suite.feature.name) {
439
+ featureName = t.suite.feature.name
440
+ }
349
441
 
350
442
  if (t.err) {
351
443
  const errorMessage = t.err.message || t.err.toString() || 'Test failed'
@@ -378,9 +470,10 @@ module.exports = function (config) {
378
470
  passes: passedTests,
379
471
  failures: failedTests,
380
472
  pending: pendingTests,
381
- skipped: skippedTests,
382
473
  duration: reportData.duration,
383
474
  failedHooks: result.stats?.failedHooks || 0,
475
+ flaky: flakyTests,
476
+ artifacts: totalArtifacts,
384
477
  }
385
478
 
386
479
  // Debug logging for final stats
@@ -410,15 +503,20 @@ module.exports = function (config) {
410
503
  // Always overwrite the file with the latest complete data from this worker
411
504
  // This prevents double-counting when the event is triggered multiple times
412
505
  fs.writeFileSync(jsonPath, safeJsonStringify(reportData))
413
- output.print(`HTML Reporter: Generated worker JSON results: ${jsonFileName}`)
506
+ output.debug(`HTML Reporter: Generated worker JSON results: ${jsonFileName}`)
414
507
  } catch (error) {
415
- output.print(`HTML Reporter: Failed to write worker JSON: ${error.message}`)
508
+ output.debug(`HTML Reporter: Failed to write worker JSON: ${error.message}`)
416
509
  }
417
510
  return
418
511
  }
419
512
 
420
513
  // Single process mode - generate report normally
421
- generateHtmlReport(reportData, options)
514
+ try {
515
+ await generateHtmlReport(reportData, options)
516
+ } catch (error) {
517
+ output.print(`Failed to generate HTML report: ${error.message}`)
518
+ output.debug(`HTML Reporter error stack: ${error.stack}`)
519
+ }
422
520
 
423
521
  // Export stats if configured
424
522
  if (options.exportStats) {
@@ -542,8 +640,8 @@ module.exports = function (config) {
542
640
  const testName = replicateTestToFileName(originalTestName)
543
641
  const featureName = replicateTestToFileName(originalFeatureName)
544
642
 
545
- output.print(`HTML Reporter: Original test title: "${originalTestName}"`)
546
- output.print(`HTML Reporter: CodeceptJS filename: "${testName}"`)
643
+ output.debug(`HTML Reporter: Original test title: "${originalTestName}"`)
644
+ output.debug(`HTML Reporter: CodeceptJS filename: "${testName}"`)
547
645
 
548
646
  // Generate possible screenshot names based on CodeceptJS patterns
549
647
  const possibleNames = [
@@ -567,19 +665,19 @@ module.exports = function (config) {
567
665
  'failure.jpg',
568
666
  ]
569
667
 
570
- output.print(`HTML Reporter: Checking ${possibleNames.length} possible screenshot names for "${testName}"`)
668
+ output.debug(`HTML Reporter: Checking ${possibleNames.length} possible screenshot names for "${testName}"`)
571
669
 
572
670
  // Search for screenshots in possible directories
573
671
  for (const dir of possibleDirs) {
574
- output.print(`HTML Reporter: Checking directory: ${dir}`)
672
+ output.debug(`HTML Reporter: Checking directory: ${dir}`)
575
673
  if (!fs.existsSync(dir)) {
576
- output.print(`HTML Reporter: Directory does not exist: ${dir}`)
674
+ output.debug(`HTML Reporter: Directory does not exist: ${dir}`)
577
675
  continue
578
676
  }
579
677
 
580
678
  try {
581
679
  const files = fs.readdirSync(dir)
582
- output.print(`HTML Reporter: Found ${files.length} files in ${dir}`)
680
+ output.debug(`HTML Reporter: Found ${files.length} files in ${dir}`)
583
681
 
584
682
  // Look for exact matches first
585
683
  for (const name of possibleNames) {
@@ -587,7 +685,7 @@ module.exports = function (config) {
587
685
  const fullPath = path.join(dir, name)
588
686
  if (!screenshots.includes(fullPath)) {
589
687
  screenshots.push(fullPath)
590
- output.print(`HTML Reporter: Found screenshot: ${fullPath}`)
688
+ output.debug(`HTML Reporter: Found screenshot: ${fullPath}`)
591
689
  }
592
690
  }
593
691
  }
@@ -618,16 +716,16 @@ module.exports = function (config) {
618
716
  const fullPath = path.join(dir, file)
619
717
  if (!screenshots.includes(fullPath)) {
620
718
  screenshots.push(fullPath)
621
- output.print(`HTML Reporter: Found related screenshot: ${fullPath}`)
719
+ output.debug(`HTML Reporter: Found related screenshot: ${fullPath}`)
622
720
  }
623
721
  }
624
722
  } catch (error) {
625
723
  // Ignore directory read errors
626
- output.print(`HTML Reporter: Could not read directory ${dir}: ${error.message}`)
724
+ output.debug(`HTML Reporter: Could not read directory ${dir}: ${error.message}`)
627
725
  }
628
726
  }
629
727
  } catch (error) {
630
- output.print(`HTML Reporter: Error collecting screenshots: ${error.message}`)
728
+ output.debug(`HTML Reporter: Error collecting screenshots: ${error.message}`)
631
729
  }
632
730
 
633
731
  return screenshots
@@ -654,7 +752,7 @@ module.exports = function (config) {
654
752
  const statsPath = path.resolve(reportDir, config.exportStatsPath)
655
753
 
656
754
  const exportData = {
657
- timestamp: data.endTime.toISOString(),
755
+ timestamp: data.endTime, // Already an ISO string
658
756
  duration: data.duration,
659
757
  stats: data.stats,
660
758
  retries: data.retries,
@@ -698,7 +796,7 @@ module.exports = function (config) {
698
796
 
699
797
  // Add current run to history
700
798
  history.unshift({
701
- timestamp: data.endTime.toISOString(),
799
+ timestamp: data.endTime, // Already an ISO string
702
800
  duration: data.duration,
703
801
  stats: data.stats,
704
802
  retries: data.retries.length,
@@ -725,11 +823,11 @@ module.exports = function (config) {
725
823
  const jsonFiles = fs.readdirSync(reportDir).filter(file => file.startsWith('worker-') && file.endsWith('-results.json'))
726
824
 
727
825
  if (jsonFiles.length === 0) {
728
- output.print('HTML Reporter: No worker JSON results found to consolidate')
826
+ output.debug('HTML Reporter: No worker JSON results found to consolidate')
729
827
  return
730
828
  }
731
829
 
732
- output.print(`HTML Reporter: Found ${jsonFiles.length} worker JSON files to consolidate`)
830
+ output.debug(`HTML Reporter: Found ${jsonFiles.length} worker JSON files to consolidate`)
733
831
 
734
832
  // Initialize consolidated data structure
735
833
  const consolidatedData = {
@@ -758,6 +856,10 @@ module.exports = function (config) {
758
856
  try {
759
857
  const workerData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
760
858
 
859
+ // Extract worker ID from filename (e.g., "worker-0-results.json" -> 0)
860
+ const workerIdMatch = jsonFile.match(/worker-(\d+)-results\.json/)
861
+ const workerIndex = workerIdMatch ? parseInt(workerIdMatch[1], 10) : undefined
862
+
761
863
  // Merge stats
762
864
  if (workerData.stats) {
763
865
  consolidatedData.stats.passes += workerData.stats.passes || 0
@@ -769,8 +871,14 @@ module.exports = function (config) {
769
871
  consolidatedData.stats.failedHooks += workerData.stats.failedHooks || 0
770
872
  }
771
873
 
772
- // Merge tests and failures
773
- if (workerData.tests) consolidatedData.tests.push(...workerData.tests)
874
+ // Merge tests and add worker index
875
+ if (workerData.tests) {
876
+ const testsWithWorkerIndex = workerData.tests.map(test => ({
877
+ ...test,
878
+ workerIndex: workerIndex,
879
+ }))
880
+ consolidatedData.tests.push(...testsWithWorkerIndex)
881
+ }
774
882
  if (workerData.failures) consolidatedData.failures.push(...workerData.failures)
775
883
  if (workerData.hooks) consolidatedData.hooks.push(...workerData.hooks)
776
884
  if (workerData.retries) consolidatedData.retries.push(...workerData.retries)
@@ -821,9 +929,9 @@ module.exports = function (config) {
821
929
  saveTestHistory(consolidatedData, config)
822
930
  }
823
931
 
824
- output.print(`HTML Reporter: Successfully consolidated ${jsonFiles.length} worker reports`)
932
+ output.debug(`HTML Reporter: Successfully consolidated ${jsonFiles.length} worker reports`)
825
933
  } catch (error) {
826
- output.print(`HTML Reporter: Failed to consolidate worker reports: ${error.message}`)
934
+ output.debug(`HTML Reporter: Failed to consolidate worker reports: ${error.message}`)
827
935
  }
828
936
  }
829
937
 
@@ -844,7 +952,7 @@ module.exports = function (config) {
844
952
 
845
953
  // Add current run to history for chart display (before saving to file)
846
954
  const currentRun = {
847
- timestamp: data.endTime.toISOString(),
955
+ timestamp: data.endTime, // Already an ISO string
848
956
  duration: data.duration,
849
957
  stats: data.stats,
850
958
  retries: data.retries.length,
@@ -863,7 +971,7 @@ module.exports = function (config) {
863
971
 
864
972
  const html = template(getHtmlTemplate(), {
865
973
  title: `CodeceptJS Test Report v${Codecept.version()}`,
866
- timestamp: data.endTime.toISOString(),
974
+ timestamp: data.endTime, // Already an ISO string
867
975
  duration: formatDuration(data.duration),
868
976
  stats: JSON.stringify(data.stats),
869
977
  history: JSON.stringify(history),
@@ -887,6 +995,10 @@ module.exports = function (config) {
887
995
  const failed = stats.failures || 0
888
996
  const pending = stats.pending || 0
889
997
  const total = stats.tests || 0
998
+ const flaky = stats.flaky || 0
999
+ const artifactCount = stats.artifacts || 0
1000
+ const passRate = total > 0 ? ((passed / total) * 100).toFixed(1) : '0.0'
1001
+ const failRate = total > 0 ? ((failed / total) * 100).toFixed(1) : '0.0'
890
1002
 
891
1003
  return `
892
1004
  <div class="stats-cards">
@@ -903,9 +1015,21 @@ module.exports = function (config) {
903
1015
  <span class="stat-number">${failed}</span>
904
1016
  </div>
905
1017
  <div class="stat-card pending">
906
- <h3>Pending</h3>
1018
+ <h3>Skipped</h3>
907
1019
  <span class="stat-number">${pending}</span>
908
1020
  </div>
1021
+ <div class="stat-card flaky">
1022
+ <h3>Flaky</h3>
1023
+ <span class="stat-number">${flaky}</span>
1024
+ </div>
1025
+ <div class="stat-card artifacts">
1026
+ <h3>Artifacts</h3>
1027
+ <span class="stat-number">${artifactCount}</span>
1028
+ </div>
1029
+ </div>
1030
+ <div class="metrics-summary">
1031
+ <span>Pass Rate: <strong>${passRate}%</strong></span>
1032
+ <span>Fail Rate: <strong>${failRate}%</strong></span>
909
1033
  </div>
910
1034
  <div class="pie-chart-container">
911
1035
  <canvas id="statsChart" width="300" height="300"></canvas>
@@ -926,47 +1050,73 @@ module.exports = function (config) {
926
1050
  return '<p>No tests found.</p>'
927
1051
  }
928
1052
 
929
- return tests
930
- .map(test => {
931
- const statusClass = test.state || 'unknown'
932
- const feature = test.isBdd && test.feature ? test.feature.name : test.parent?.title || 'Unknown Feature'
933
- const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : ''
934
- const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : ''
935
- const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : ''
936
- const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts, test.state === 'failed') : ''
937
- const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : ''
938
- const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : ''
939
- const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts) : ''
940
- const notes = test.notes && test.notes.length > 0 ? generateNotesHtml(test.notes) : ''
1053
+ // Group tests by feature name
1054
+ const grouped = {}
1055
+ tests.forEach(test => {
1056
+ const feature = test.isBdd && test.feature ? test.feature.name : test.parentTitle || test.suiteTitle || test.parent?.title || test.suite?.title || 'Unknown Feature'
1057
+ if (!grouped[feature]) grouped[feature] = []
1058
+ grouped[feature].push(test)
1059
+ })
941
1060
 
1061
+ // Render each feature section
1062
+ return Object.entries(grouped)
1063
+ .map(([feature, tests]) => {
1064
+ const featureId = feature.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
942
1065
  return `
943
- <div class="test-item ${statusClass}${test.isBdd ? ' bdd-test' : ''}" id="test-${test.id}" data-feature="${escapeHtml(feature)}" data-status="${statusClass}" data-tags="${(test.tags || []).join(',')}" data-retries="${test.retryAttempts || 0}" data-type="${test.isBdd ? 'bdd' : 'regular'}">
944
- <div class="test-header" onclick="toggleTestDetails('test-${test.id}')">
945
- <span class="test-status ${statusClass}">●</span>
946
- <div class="test-info">
947
- <h3 class="test-title">${test.isBdd ? `Scenario: ${test.title}` : test.title}</h3>
948
- <div class="test-meta-line">
949
- <span class="test-feature">${test.isBdd ? 'Feature: ' : ''}${feature}</span>
950
- ${test.uid ? `<span class="test-uid">${test.uid}</span>` : ''}
951
- <span class="test-duration">${formatDuration(test.duration)}</span>
952
- ${test.retryAttempts > 0 ? `<span class="retry-badge">${test.retryAttempts} retries</span>` : ''}
953
- ${test.isBdd ? '<span class="bdd-badge">Gherkin</span>' : ''}
954
- </div>
1066
+ <section class="feature-group">
1067
+ <h3 class="feature-group-title" onclick="toggleFeatureGroup('${featureId}')">
1068
+ ${escapeHtml(feature)}
1069
+ <span class="toggle-icon">▼</span>
1070
+ </h3>
1071
+ <div class="feature-tests" id="feature-${featureId}">
1072
+ ${tests
1073
+ .map(test => {
1074
+ const statusClass = test.state || 'unknown'
1075
+ const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : ''
1076
+ const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : ''
1077
+ const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : ''
1078
+ const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts, test.state === 'failed') : ''
1079
+ const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : ''
1080
+ const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : ''
1081
+ const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts, test.state) : ''
1082
+ const notes = test.notes && test.notes.length > 0 ? generateNotesHtml(test.notes) : ''
1083
+
1084
+ // Worker badge - show worker index if test has worker info
1085
+ const workerBadge = test.workerIndex !== undefined ? `<span class="worker-badge worker-${test.workerIndex}">Worker ${test.workerIndex}</span>` : ''
1086
+
1087
+ return `
1088
+ <div class="test-item ${statusClass}${test.isBdd ? ' bdd-test' : ''}" id="test-${test.id}" data-feature="${escapeHtml(feature)}" data-status="${statusClass}" data-tags="${(test.tags || []).join(',')}" data-retries="${test.retryAttempts || 0}" data-type="${test.isBdd ? 'bdd' : 'regular'}">
1089
+ <div class="test-header" onclick="toggleTestDetails('test-${test.id}')">
1090
+ <span class="test-status ${statusClass}">●</span>
1091
+ <div class="test-info">
1092
+ <h3 class="test-title">${test.isBdd ? `Scenario: ${test.title}` : test.title}</h3>
1093
+ <div class="test-meta-line">
1094
+ ${workerBadge}
1095
+ ${test.uid ? `<span class="test-uid">${test.uid}</span>` : ''}
1096
+ <span class="test-duration">${formatDuration(test.duration)}</span>
1097
+ ${test.retryAttempts > 0 ? `<span class="retry-badge">${test.retryAttempts} retries</span>` : ''}
1098
+ ${test.isBdd ? '<span class="bdd-badge">Gherkin</span>' : ''}
1099
+ </div>
1100
+ </div>
1101
+ </div>
1102
+ <div class="test-details" id="details-test-${test.id}">
1103
+ ${test.err ? `<div class="error-message"><pre>${escapeHtml(getErrorMessage(test))}</pre></div>` : ''}
1104
+ ${featureDetails}
1105
+ ${tags}
1106
+ ${metadata}
1107
+ ${retries}
1108
+ ${notes}
1109
+ ${hooks}
1110
+ ${steps}
1111
+ ${artifacts}
1112
+ </div>
1113
+ </div>
1114
+ `
1115
+ })
1116
+ .join('')}
955
1117
  </div>
956
- </div>
957
- <div class="test-details" id="details-test-${test.id}">
958
- ${test.err ? `<div class="error-message"><pre>${escapeHtml(getErrorMessage(test))}</pre></div>` : ''}
959
- ${featureDetails}
960
- ${tags}
961
- ${metadata}
962
- ${retries}
963
- ${notes}
964
- ${hooks}
965
- ${steps}
966
- ${artifacts}
967
- </div>
968
- </div>
969
- `
1118
+ </section>
1119
+ `
970
1120
  })
971
1121
  .join('')
972
1122
  }
@@ -1056,13 +1206,19 @@ module.exports = function (config) {
1056
1206
  const statusClass = hook.status || 'unknown'
1057
1207
  const hookType = hook.type || 'hook'
1058
1208
  const hookTitle = hook.title || `${hookType} hook`
1209
+ const location = hook.location ? `<div class="hook-location">Location: ${escapeHtml(hook.location)}</div>` : ''
1210
+ const context = hook.context ? `<div class="hook-context">Test: ${escapeHtml(hook.context.testTitle || 'N/A')}, Suite: ${escapeHtml(hook.context.suiteTitle || 'N/A')}</div>` : ''
1059
1211
 
1060
1212
  return `
1061
1213
  <div class="hook-item ${statusClass}">
1062
1214
  <span class="hook-status ${statusClass}">●</span>
1063
- <span class="hook-title">${hookType}: ${hookTitle}</span>
1064
- <span class="hook-duration">${formatDuration(hook.duration)}</span>
1065
- ${hook.error ? `<div class="hook-error">${escapeHtml(hook.error)}</div>` : ''}
1215
+ <div class="hook-content">
1216
+ <span class="hook-title">${hookType}: ${hookTitle}</span>
1217
+ <span class="hook-duration">${formatDuration(hook.duration)}</span>
1218
+ ${location}
1219
+ ${context}
1220
+ ${hook.error ? `<div class="hook-error">${escapeHtml(hook.error)}</div>` : ''}
1221
+ </div>
1066
1222
  </div>
1067
1223
  `
1068
1224
  })
@@ -1122,12 +1278,21 @@ module.exports = function (config) {
1122
1278
  `
1123
1279
  }
1124
1280
 
1125
- function generateTestRetryHtml(retryAttempts) {
1281
+ function generateTestRetryHtml(retryAttempts, testState) {
1282
+ // Enhanced retry history display showing whether test eventually passed or failed
1283
+ const statusBadge = testState === 'passed' ? '<span class="retry-status-badge passed">✓ Eventually Passed</span>' : '<span class="retry-status-badge failed">✗ Eventually Failed</span>'
1284
+
1126
1285
  return `
1127
1286
  <div class="retry-section">
1128
- <h4>Retry Information:</h4>
1287
+ <h4>Retry History:</h4>
1129
1288
  <div class="retry-info">
1130
- <span class="retry-count">Total retry attempts: <strong>${retryAttempts}</strong></span>
1289
+ <div class="retry-summary">
1290
+ <span class="retry-count">Total retry attempts: <strong>${retryAttempts}</strong></span>
1291
+ ${statusBadge}
1292
+ </div>
1293
+ <div class="retry-description">
1294
+ This test was retried <strong>${retryAttempts}</strong> time${retryAttempts > 1 ? 's' : ''} before ${testState === 'passed' ? 'passing' : 'failing'}.
1295
+ </div>
1131
1296
  </div>
1132
1297
  </div>
1133
1298
  `
@@ -1135,19 +1300,19 @@ module.exports = function (config) {
1135
1300
 
1136
1301
  function generateArtifactsHtml(artifacts, isFailedTest = false) {
1137
1302
  if (!artifacts || artifacts.length === 0) {
1138
- output.print(`HTML Reporter: No artifacts found for test`)
1303
+ output.debug(`HTML Reporter: No artifacts found for test`)
1139
1304
  return ''
1140
1305
  }
1141
1306
 
1142
- output.print(`HTML Reporter: Processing ${artifacts.length} artifacts, isFailedTest: ${isFailedTest}`)
1143
- output.print(`HTML Reporter: Artifacts: ${JSON.stringify(artifacts)}`)
1307
+ output.debug(`HTML Reporter: Processing ${artifacts.length} artifacts, isFailedTest: ${isFailedTest}`)
1308
+ output.debug(`HTML Reporter: Artifacts: ${JSON.stringify(artifacts)}`)
1144
1309
 
1145
1310
  // Separate screenshots from other artifacts
1146
1311
  const screenshots = []
1147
1312
  const otherArtifacts = []
1148
1313
 
1149
1314
  artifacts.forEach(artifact => {
1150
- output.print(`HTML Reporter: Processing artifact: ${artifact} (type: ${typeof artifact})`)
1315
+ output.debug(`HTML Reporter: Processing artifact: ${artifact} (type: ${typeof artifact})`)
1151
1316
 
1152
1317
  // Handle different artifact formats
1153
1318
  let artifactPath = artifact
@@ -1162,14 +1327,14 @@ module.exports = function (config) {
1162
1327
  // Check if it's a screenshot file
1163
1328
  if (typeof artifactPath === 'string' && artifactPath.match(/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i)) {
1164
1329
  screenshots.push(artifactPath)
1165
- output.print(`HTML Reporter: Found screenshot: ${artifactPath}`)
1330
+ output.debug(`HTML Reporter: Found screenshot: ${artifactPath}`)
1166
1331
  } else {
1167
1332
  otherArtifacts.push(artifact)
1168
- output.print(`HTML Reporter: Found other artifact: ${artifact}`)
1333
+ output.debug(`HTML Reporter: Found other artifact: ${artifact}`)
1169
1334
  }
1170
1335
  })
1171
1336
 
1172
- output.print(`HTML Reporter: Found ${screenshots.length} screenshots and ${otherArtifacts.length} other artifacts`)
1337
+ output.debug(`HTML Reporter: Found ${screenshots.length} screenshots and ${otherArtifacts.length} other artifacts`)
1173
1338
 
1174
1339
  let artifactsHtml = ''
1175
1340
 
@@ -1198,7 +1363,7 @@ module.exports = function (config) {
1198
1363
  }
1199
1364
  }
1200
1365
 
1201
- output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1366
+ output.debug(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1202
1367
 
1203
1368
  return `
1204
1369
  <div class="screenshot-container">
@@ -1242,7 +1407,7 @@ module.exports = function (config) {
1242
1407
  }
1243
1408
  }
1244
1409
 
1245
- output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1410
+ output.debug(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1246
1411
  return `<img src="${relativePath}" alt="Screenshot" class="artifact-image" onclick="openImageModal(this.src)"/>`
1247
1412
  })
1248
1413
  .join('')
@@ -1373,6 +1538,30 @@ module.exports = function (config) {
1373
1538
 
1374
1539
  function escapeHtml(text) {
1375
1540
  if (!text) return ''
1541
+ // Convert non-string values to strings before escaping
1542
+ if (typeof text !== 'string') {
1543
+ // Handle arrays by recursively flattening and joining with commas
1544
+ if (Array.isArray(text)) {
1545
+ // Recursive helper to flatten deeply nested arrays with depth limit to prevent stack overflow
1546
+ const flattenArray = (arr, depth = 0, maxDepth = 100) => {
1547
+ if (depth >= maxDepth) {
1548
+ // Safety limit reached, return string representation
1549
+ return String(arr)
1550
+ }
1551
+ return arr
1552
+ .map(item => {
1553
+ if (Array.isArray(item)) {
1554
+ return flattenArray(item, depth + 1, maxDepth)
1555
+ }
1556
+ return String(item)
1557
+ })
1558
+ .join(', ')
1559
+ }
1560
+ text = flattenArray(text)
1561
+ } else {
1562
+ text = String(text)
1563
+ }
1564
+ }
1376
1565
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
1377
1566
  }
1378
1567
 
@@ -1488,8 +1677,12 @@ module.exports = function (config) {
1488
1677
  if (!systemInfo) return ''
1489
1678
 
1490
1679
  const formatInfo = (key, value) => {
1680
+ // Handle array values (e.g., ['Node', '22.14.0', 'path'])
1491
1681
  if (Array.isArray(value) && value.length > 1) {
1492
- return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value[1])}</span></div>`
1682
+ // value[1] might be an array itself (e.g., edgeInfo: ['Edge', ['Chromium (140.0.3485.54)'], 'N/A'])
1683
+ // escapeHtml now handles this, but we can also flatten it here for clarity
1684
+ const displayValue = value[1]
1685
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(displayValue)}</span></div>`
1493
1686
  } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') {
1494
1687
  return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value)}</span></div>`
1495
1688
  }
@@ -1531,32 +1724,48 @@ module.exports = function (config) {
1531
1724
  <!DOCTYPE html>
1532
1725
  <html lang="en">
1533
1726
  <head>
1534
- <meta charset="UTF-8">
1535
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1536
- <title>{{title}}</title>
1537
- <style>{{cssStyles}}</style>
1727
+ <meta charset="UTF-8">
1728
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1729
+ <title>{{title}}</title>
1730
+ <style>{{cssStyles}}</style>
1538
1731
  </head>
1539
1732
  <body>
1540
- <header class="report-header">
1541
- <h1>{{title}}</h1>
1542
- <div class="report-meta">
1543
- <span>Generated: {{timestamp}}</span>
1544
- <span>Duration: {{duration}}</span>
1545
- </div>
1546
- </header>
1733
+ <header class="report-header">
1734
+ <h1>{{title}}</h1>
1735
+ <div class="report-meta">
1736
+ <span>Generated: {{timestamp}}</span>
1737
+ <span>Duration: {{duration}}</span>
1738
+ </div>
1739
+ </header>
1547
1740
 
1548
- <main class="report-content">
1549
- {{systemInfoHtml}}
1741
+ <main class="report-content">
1742
+ {{systemInfoHtml}}
1550
1743
 
1551
- <section class="stats-section">
1552
- <h2>Test Statistics</h2>
1553
- {{statsHtml}}
1744
+ <section class="stats-section">
1745
+ <h2>Test Statistics</h2>
1746
+ {{statsHtml}}
1747
+ </section>
1748
+
1749
+ <section class="test-performance-section">
1750
+ <h2>Test Performance Analysis</h2>
1751
+ <div class="performance-container">
1752
+ <div class="performance-group">
1753
+ <h3>⏱️ Longest Running Tests</h3>
1754
+ <div id="longestTests" class="performance-list"></div>
1755
+ </div>
1756
+ <div class="performance-group">
1757
+ <h3>⚡ Fastest Tests</h3>
1758
+ <div id="fastestTests" class="performance-list"></div>
1759
+ </div>
1760
+ </div>
1554
1761
  </section>
1555
1762
 
1556
1763
  <section class="history-section" style="display: {{showHistory}};">
1557
- <h2>Test History</h2>
1764
+ <h2>Test Execution History</h2>
1765
+ <div class="history-stats" id="historyStats"></div>
1766
+ <div class="history-timeline" id="historyTimeline"></div>
1558
1767
  <div class="history-chart-container">
1559
- <canvas id="historyChart" width="800" height="300"></canvas>
1768
+ <canvas id="historyChart" width="1600" height="600"></canvas>
1560
1769
  </div>
1561
1770
  </section>
1562
1771
 
@@ -1607,10 +1816,10 @@ module.exports = function (config) {
1607
1816
  </div>
1608
1817
  </section>
1609
1818
 
1610
- <section class="retries-section" style="display: {{showRetries}};">
1611
- <h2>Test Retries</h2>
1819
+ <section class="retries-section" style="display: none;">
1820
+ <h2>Test Retries (Moved to Test Details)</h2>
1612
1821
  <div class="retries-container">
1613
- {{retriesHtml}}
1822
+ <p>Retry information is now shown in each test's details section.</p>
1614
1823
  </div>
1615
1824
  </section>
1616
1825
 
@@ -1675,7 +1884,7 @@ body {
1675
1884
  padding: 0 1rem;
1676
1885
  }
1677
1886
 
1678
- .stats-section, .tests-section, .retries-section, .filters-section, .history-section, .system-info-section {
1887
+ .stats-section, .tests-section, .retries-section, .filters-section, .history-section, .system-info-section, .test-performance-section {
1679
1888
  background: white;
1680
1889
  margin-bottom: 2rem;
1681
1890
  border-radius: 8px;
@@ -1683,7 +1892,7 @@ body {
1683
1892
  overflow: hidden;
1684
1893
  }
1685
1894
 
1686
- .stats-section h2, .tests-section h2, .retries-section h2, .filters-section h2, .history-section h2 {
1895
+ .stats-section h2, .tests-section h2, .retries-section h2, .filters-section h2, .history-section h2, .test-performance-section h2 {
1687
1896
  background: #34495e;
1688
1897
  color: white;
1689
1898
  padding: 1rem;
@@ -1710,6 +1919,23 @@ body {
1710
1919
  .stat-card.passed { background: #27ae60; }
1711
1920
  .stat-card.failed { background: #e74c3c; }
1712
1921
  .stat-card.pending { background: #f39c12; }
1922
+ .stat-card.flaky { background: #e67e22; }
1923
+ .stat-card.artifacts { background: #9b59b6; }
1924
+
1925
+ .metrics-summary {
1926
+ display: flex;
1927
+ justify-content: center;
1928
+ gap: 2rem;
1929
+ padding: 1rem;
1930
+ background: #f8f9fa;
1931
+ border-radius: 6px;
1932
+ margin: 1rem 0;
1933
+ font-size: 1rem;
1934
+ }
1935
+
1936
+ .metrics-summary span {
1937
+ color: #34495e;
1938
+ }
1713
1939
 
1714
1940
  .stat-card h3 {
1715
1941
  font-size: 0.9rem;
@@ -1737,6 +1963,54 @@ body {
1737
1963
  height: auto;
1738
1964
  }
1739
1965
 
1966
+ .feature-group {
1967
+ margin-bottom: 2.5rem;
1968
+ border: 2px solid #3498db;
1969
+ border-radius: 12px;
1970
+ overflow: hidden;
1971
+ background: white;
1972
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
1973
+ }
1974
+
1975
+ .feature-group-title {
1976
+ background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
1977
+ color: white;
1978
+ padding: 1.2rem 1.5rem;
1979
+ margin: 0;
1980
+ font-size: 1.4rem;
1981
+ font-weight: 600;
1982
+ display: flex;
1983
+ align-items: center;
1984
+ justify-content: space-between;
1985
+ cursor: pointer;
1986
+ transition: all 0.3s ease;
1987
+ user-select: none;
1988
+ }
1989
+
1990
+ .feature-group-title:hover {
1991
+ background: linear-gradient(135deg, #2980b9 0%, #21618c 100%);
1992
+ }
1993
+
1994
+ .feature-group-title .toggle-icon {
1995
+ font-size: 1.2rem;
1996
+ transition: transform 0.3s ease;
1997
+ }
1998
+
1999
+ .feature-group-title .toggle-icon.rotated {
2000
+ transform: rotate(180deg);
2001
+ }
2002
+
2003
+ .feature-tests {
2004
+ padding: 0;
2005
+ transition: max-height 0.3s ease, opacity 0.3s ease;
2006
+ }
2007
+
2008
+ .feature-tests.collapsed {
2009
+ max-height: 0;
2010
+ opacity: 0;
2011
+ overflow: hidden;
2012
+ }
2013
+
1740
2014
  .test-item {
1741
2015
  border-bottom: 1px solid #eee;
1742
2016
  margin: 0;
@@ -1814,9 +2088,64 @@ body {
1814
2088
  font-weight: bold;
1815
2089
  }
1816
2090
 
2091
+ .worker-badge {
2092
+ background: #16a085;
2093
+ color: white;
2094
+ padding: 0.25rem 0.5rem;
2095
+ border-radius: 4px;
2096
+ font-size: 0.7rem;
2097
+ font-weight: bold;
2098
+ }
2099
+
2100
+ /* Different colors for each worker index */
2101
+ .worker-badge.worker-0 {
2102
+ background: #3498db; /* Blue */
2103
+ }
2104
+
2105
+ .worker-badge.worker-1 {
2106
+ background: #e74c3c; /* Red */
2107
+ }
2108
+
2109
+ .worker-badge.worker-2 {
2110
+ background: #2ecc71; /* Green */
2111
+ }
2112
+
2113
+ .worker-badge.worker-3 {
2114
+ background: #f39c12; /* Orange */
2115
+ }
2116
+
2117
+ .worker-badge.worker-4 {
2118
+ background: #9b59b6; /* Purple */
2119
+ }
2120
+
2121
+ .worker-badge.worker-5 {
2122
+ background: #1abc9c; /* Turquoise */
2123
+ }
2124
+
2125
+ .worker-badge.worker-6 {
2126
+ background: #e67e22; /* Carrot */
2127
+ }
2128
+
2129
+ .worker-badge.worker-7 {
2130
+ background: #34495e; /* Dark Blue-Gray */
2131
+ }
2132
+
2133
+ .worker-badge.worker-8 {
2134
+ background: #16a085; /* Teal */
2135
+ }
2136
+
2137
+ .worker-badge.worker-9 {
2138
+ background: #c0392b; /* Dark Red */
2139
+ }
2140
+
1817
2141
  .test-duration {
1818
- font-size: 0.8rem;
1819
- color: #7f8c8d;
2142
+ font-size: 0.85rem;
2143
+ font-weight: 600;
2144
+ color: #2c3e50;
2145
+ background: #ecf0f1;
2146
+ padding: 0.25rem 0.5rem;
2147
+ border-radius: 4px;
2148
+ font-family: 'Monaco', 'Courier New', monospace;
1820
2149
  }
1821
2150
 
1822
2151
  .test-details {
@@ -1854,27 +2183,39 @@ body {
1854
2183
 
1855
2184
  .hook-item {
1856
2185
  display: flex;
1857
- align-items: center;
1858
- padding: 0.5rem 0;
1859
- border-bottom: 1px solid #ecf0f1;
2186
+ align-items: flex-start;
2187
+ padding: 0.75rem;
2188
+ border: 1px solid #ecf0f1;
2189
+ border-radius: 4px;
2190
+ margin-bottom: 0.5rem;
2191
+ background: #fafafa;
1860
2192
  }
1861
2193
 
1862
2194
  .hook-item:last-child {
1863
- border-bottom: none;
2195
+ margin-bottom: 0;
1864
2196
  }
1865
2197
 
1866
2198
  .hook-status {
1867
- margin-right: 0.5rem;
2199
+ margin-right: 0.75rem;
2200
+ flex-shrink: 0;
2201
+ margin-top: 0.2rem;
1868
2202
  }
1869
2203
 
1870
2204
  .hook-status.passed { color: #27ae60; }
1871
2205
  .hook-status.failed { color: #e74c3c; }
1872
2206
 
1873
- .hook-title {
2207
+ .hook-content {
1874
2208
  flex: 1;
2209
+ display: flex;
2210
+ flex-direction: column;
2211
+ gap: 0.25rem;
2212
+ }
2213
+
2214
+ .hook-title {
1875
2215
  font-family: 'Courier New', monospace;
1876
2216
  font-size: 0.9rem;
1877
2217
  font-weight: bold;
2218
+ color: #2c3e50;
1878
2219
  }
1879
2220
 
1880
2221
  .hook-duration {
@@ -1882,8 +2223,13 @@ body {
1882
2223
  color: #7f8c8d;
1883
2224
  }
1884
2225
 
2226
+ .hook-location, .hook-context {
2227
+ font-size: 0.8rem;
2228
+ color: #6c757d;
2229
+ font-style: italic;
2230
+ }
2231
+
1885
2232
  .hook-error {
1886
- width: 100%;
1887
2233
  margin-top: 0.5rem;
1888
2234
  padding: 0.5rem;
1889
2235
  background: #fee;
@@ -2172,11 +2518,22 @@ body {
2172
2518
  }
2173
2519
 
2174
2520
  /* Retry Information */
2521
+ .retry-section {
2522
+ margin-top: 1rem;
2523
+ }
2524
+
2175
2525
  .retry-info {
2176
- padding: 0.5rem;
2177
- background: #fef9e7;
2526
+ padding: 1rem;
2527
+ background: #fff9e6;
2178
2528
  border-radius: 4px;
2179
- border-left: 3px solid #f39c12;
2529
+ border-left: 4px solid #f39c12;
2530
+ }
2531
+
2532
+ .retry-summary {
2533
+ display: flex;
2534
+ align-items: center;
2535
+ gap: 1rem;
2536
+ margin-bottom: 0.5rem;
2180
2537
  }
2181
2538
 
2182
2539
  .retry-count {
@@ -2184,6 +2541,29 @@ body {
2184
2541
  font-weight: 500;
2185
2542
  }
2186
2543
 
2544
+ .retry-status-badge {
2545
+ padding: 0.25rem 0.75rem;
2546
+ border-radius: 4px;
2547
+ font-size: 0.85rem;
2548
+ font-weight: bold;
2549
+ }
2550
+
2551
+ .retry-status-badge.passed {
2552
+ background: #27ae60;
2553
+ color: white;
2554
+ }
2555
+
2556
+ .retry-status-badge.failed {
2557
+ background: #e74c3c;
2558
+ color: white;
2559
+ }
2560
+
2561
+ .retry-description {
2562
+ font-size: 0.9rem;
2563
+ color: #6c757d;
2564
+ font-style: italic;
2565
+ }
2566
+
2187
2567
  /* Retries Section */
2188
2568
  .retry-item {
2189
2569
  padding: 1rem;
@@ -2229,6 +2609,92 @@ body {
2229
2609
  }
2230
2610
 
2231
2611
  /* History Chart */
2612
+ .history-stats {
2613
+ padding: 1.5rem;
2614
+ background: #f8f9fa;
2615
+ border-bottom: 1px solid #e9ecef;
2616
+ }
2617
+
2618
+ .history-stats-grid {
2619
+ display: grid;
2620
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2621
+ gap: 1rem;
2622
+ }
2623
+
2624
+ .history-stat-item {
2625
+ background: white;
2626
+ padding: 1rem;
2627
+ border-radius: 6px;
2628
+ border-left: 4px solid #3498db;
2629
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
2630
+ }
2631
+
2632
+ .history-stat-item h4 {
2633
+ margin: 0 0 0.5rem 0;
2634
+ font-size: 0.9rem;
2635
+ color: #7f8c8d;
2636
+ text-transform: uppercase;
2637
+ }
2638
+
2639
+ .history-stat-item .value {
2640
+ font-size: 1.5rem;
2641
+ font-weight: bold;
2642
+ color: #2c3e50;
2643
+ }
2644
+
2645
+ .history-timeline {
2646
+ padding: 1.5rem;
2647
+ background: white;
2648
+ }
2649
+
2650
+ .timeline-item {
2651
+ display: flex;
2652
+ align-items: center;
2653
+ padding: 0.75rem;
2654
+ border-left: 3px solid #3498db;
2655
+ margin-left: 1rem;
2656
+ margin-bottom: 0.5rem;
2657
+ background: #f8f9fa;
2658
+ border-radius: 0 6px 6px 0;
2659
+ transition: all 0.2s;
2660
+ }
2661
+
2662
+ .timeline-item:hover {
2663
+ background: #e9ecef;
2664
+ transform: translateX(4px);
2665
+ }
2666
+
2667
+ .timeline-time {
2668
+ min-width: 150px;
2669
+ font-weight: 600;
2670
+ color: #2c3e50;
2671
+ font-family: 'Courier New', monospace;
2672
+ }
2673
+
2674
+ .timeline-result {
2675
+ flex: 1;
2676
+ display: flex;
2677
+ gap: 1rem;
2678
+ align-items: center;
2679
+ }
2680
+
2681
+ .timeline-badge {
2682
+ padding: 0.25rem 0.5rem;
2683
+ border-radius: 4px;
2684
+ font-size: 0.85rem;
2685
+ font-weight: 600;
2686
+ }
2687
+
2688
+ .timeline-badge.success {
2689
+ background: #d4edda;
2690
+ color: #155724;
2691
+ }
2692
+
2693
+ .timeline-badge.failure {
2694
+ background: #f8d7da;
2695
+ color: #721c24;
2696
+ }
2697
+
2232
2698
  .history-chart-container {
2233
2699
  padding: 2rem 1rem;
2234
2700
  display: flex;
@@ -2240,6 +2706,87 @@ body {
2240
2706
  height: auto;
2241
2707
  }
2242
2708
 
2709
+ /* Test Performance Section */
2710
+ .performance-container {
2711
+ display: grid;
2712
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
2713
+ gap: 2rem;
2714
+ padding: 1.5rem;
2715
+ }
2716
+
2717
+ .performance-group h3 {
2718
+ margin: 0 0 1rem 0;
2719
+ color: #2c3e50;
2720
+ font-size: 1.1rem;
2721
+ padding-bottom: 0.5rem;
2722
+ border-bottom: 2px solid #3498db;
2723
+ }
2724
+
2725
+ .performance-list {
2726
+ display: flex;
2727
+ flex-direction: column;
2728
+ gap: 0.75rem;
2729
+ }
2730
+
2731
+ .performance-item {
2732
+ display: flex;
2733
+ align-items: center;
2734
+ justify-content: space-between;
2735
+ padding: 0.75rem 1rem;
2736
+ background: #f8f9fa;
2737
+ border-radius: 6px;
2738
+ border-left: 4px solid #3498db;
2739
+ transition: all 0.2s;
2740
+ }
2741
+
2742
+ .performance-item:hover {
2743
+ background: #e9ecef;
2744
+ transform: translateX(4px);
2745
+ }
2746
+
2747
+ .performance-item:nth-child(1) .performance-rank {
2748
+ background: #f39c12;
2749
+ color: white;
2750
+ }
2751
+
2752
+ .performance-item:nth-child(2) .performance-rank {
2753
+ background: #95a5a6;
2754
+ color: white;
2755
+ }
2756
+
2757
+ .performance-item:nth-child(3) .performance-rank {
2758
+ background: #cd7f32;
2759
+ color: white;
2760
+ }
2761
+
2762
+ .performance-rank {
2763
+ display: flex;
2764
+ align-items: center;
2765
+ justify-content: center;
2766
+ width: 28px;
2767
+ height: 28px;
2768
+ background: #3498db;
2769
+ color: white;
2770
+ border-radius: 50%;
2771
+ font-weight: bold;
2772
+ font-size: 0.9rem;
2773
+ margin-right: 1rem;
2774
+ flex-shrink: 0;
2775
+ }
2776
+
2777
+ .performance-name {
2778
+ flex: 1;
2779
+ font-weight: 500;
2780
+ color: #2c3e50;
2781
+ }
2782
+
2783
+ .performance-duration {
2784
+ font-weight: 600;
2785
+ color: #7f8c8d;
2786
+ font-family: 'Courier New', monospace;
2787
+ font-size: 0.9rem;
2788
+ }
2789
+
2243
2790
  /* Hidden items for filtering */
2244
2791
  .test-item.filtered-out {
2245
2792
  display: none !important;
@@ -2457,6 +3004,25 @@ body {
2457
3004
 
2458
3005
  function getJsScripts() {
2459
3006
  return `
3007
+ // Go to Top button
3008
+ function scrollToTop() {
3009
+ window.scrollTo({ top: 0, behavior: 'smooth' });
3010
+ }
3011
+
3012
+ function toggleFeatureGroup(featureId) {
3013
+ const featureTests = document.getElementById('feature-' + featureId);
3014
+ const titleElement = featureTests.previousElementSibling;
3015
+ const icon = titleElement.querySelector('.toggle-icon');
3016
+
3017
+ if (featureTests.classList.contains('collapsed')) {
3018
+ featureTests.classList.remove('collapsed');
3019
+ icon.classList.remove('rotated');
3020
+ } else {
3021
+ featureTests.classList.add('collapsed');
3022
+ icon.classList.add('rotated');
3023
+ }
3024
+ }
3025
+
2460
3026
  function toggleTestDetails(testId) {
2461
3027
  const details = document.getElementById('details-' + testId);
2462
3028
  if (details.style.display === 'none' || details.style.display === '') {
@@ -2939,9 +3505,29 @@ function drawHistoryChart() {
2939
3505
  // Initialize charts and filters
2940
3506
  document.addEventListener('DOMContentLoaded', function() {
2941
3507
 
2942
- // Draw charts
2943
- drawPieChart();
2944
- drawHistoryChart();
3508
+ // Draw charts
3509
+ drawPieChart();
3510
+ drawHistoryChart();
3511
+ renderTestPerformance();
3512
+ renderHistoryTimeline();
3513
+
3514
+ // Add Go to Top button
3515
+ const goTopBtn = document.createElement('button');
3516
+ goTopBtn.innerText = '↑ Top';
3517
+ goTopBtn.id = 'goTopBtn';
3518
+ goTopBtn.style.position = 'fixed';
3519
+ goTopBtn.style.bottom = '30px';
3520
+ goTopBtn.style.right = '30px';
3521
+ goTopBtn.style.zIndex = '9999';
3522
+ goTopBtn.style.padding = '12px 18px';
3523
+ goTopBtn.style.borderRadius = '50%';
3524
+ goTopBtn.style.background = '#27ae60';
3525
+ goTopBtn.style.color = '#fff';
3526
+ goTopBtn.style.fontSize = '20px';
3527
+ goTopBtn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
3528
+ goTopBtn.style.cursor = 'pointer';
3529
+ goTopBtn.onclick = scrollToTop;
3530
+ document.body.appendChild(goTopBtn);
2945
3531
 
2946
3532
  // Set up filter event listeners
2947
3533
  document.getElementById('statusFilter').addEventListener('change', applyFilters);
@@ -2950,6 +3536,141 @@ document.addEventListener('DOMContentLoaded', function() {
2950
3536
  document.getElementById('retryFilter').addEventListener('change', applyFilters);
2951
3537
  document.getElementById('typeFilter').addEventListener('change', applyFilters);
2952
3538
  });
3539
+
3540
+ // Render test performance analysis
3541
+ function renderTestPerformance() {
3542
+ const tests = Array.from(document.querySelectorAll('.test-item'));
3543
+ const testsWithDuration = tests.map(testEl => {
3544
+ const title = testEl.querySelector('.test-title')?.textContent || 'Unknown';
3545
+ const durationText = testEl.querySelector('.test-duration')?.textContent || '0ms';
3546
+ const durationMs = parseDuration(durationText);
3547
+ const status = testEl.dataset.status;
3548
+ return { title, duration: durationMs, durationText, status };
3549
+ }); // Don't filter out 0ms tests
3550
+
3551
+ // Sort by duration
3552
+ const longest = [...testsWithDuration].sort((a, b) => b.duration - a.duration).slice(0, 5);
3553
+ const fastest = [...testsWithDuration].sort((a, b) => a.duration - b.duration).slice(0, 5);
3554
+
3555
+ // Render longest tests
3556
+ const longestContainer = document.getElementById('longestTests');
3557
+ if (longestContainer && longest.length > 0) {
3558
+ longestContainer.innerHTML = longest.map((test, index) => \`
3559
+ <div class="performance-item">
3560
+ <span class="performance-rank">\${index + 1}</span>
3561
+ <span class="performance-name" title="\${test.title}">\${test.title.length > 60 ? test.title.substring(0, 60) + '...' : test.title}</span>
3562
+ <span class="performance-duration">\${test.durationText}</span>
3563
+ </div>
3564
+ \`).join('');
3565
+ } else if (longestContainer) {
3566
+ longestContainer.innerHTML = '<p style="color: #7f8c8d; padding: 1rem;">No test data available</p>';
3567
+ }
3568
+
3569
+ // Render fastest tests
3570
+ const fastestContainer = document.getElementById('fastestTests');
3571
+ if (fastestContainer && fastest.length > 0) {
3572
+ fastestContainer.innerHTML = fastest.map((test, index) => \`
3573
+ <div class="performance-item">
3574
+ <span class="performance-rank">\${index + 1}</span>
3575
+ <span class="performance-name" title="\${test.title}">\${test.title.length > 60 ? test.title.substring(0, 60) + '...' : test.title}</span>
3576
+ <span class="performance-duration">\${test.durationText}</span>
3577
+ </div>
3578
+ \`).join('');
3579
+ } else if (fastestContainer) {
3580
+ fastestContainer.innerHTML = '<p style="color: #7f8c8d; padding: 1rem;">No test data available</p>';
3581
+ }
3582
+ }
3583
+
3584
+ // Render history timeline
3585
+ function renderHistoryTimeline() {
3586
+ if (!window.testData || !window.testData.history || window.testData.history.length === 0) {
3587
+ return;
3588
+ }
3589
+
3590
+ const history = window.testData.history.slice().reverse(); // Most recent last
3591
+
3592
+ // Render stats
3593
+ const statsContainer = document.getElementById('historyStats');
3594
+ if (statsContainer) {
3595
+ const totalRuns = history.length;
3596
+ const avgDuration = history.reduce((sum, run) => sum + (run.duration || 0), 0) / totalRuns;
3597
+ const avgTests = Math.round(history.reduce((sum, run) => sum + (run.stats.tests || 0), 0) / totalRuns);
3598
+ const avgPassRate = history.reduce((sum, run) => {
3599
+ const total = run.stats.tests || 0;
3600
+ const passed = run.stats.passes || 0;
3601
+ return sum + (total > 0 ? (passed / total) * 100 : 0);
3602
+ }, 0) / totalRuns;
3603
+
3604
+ statsContainer.innerHTML = \`
3605
+ <div class="history-stats-grid">
3606
+ <div class="history-stat-item">
3607
+ <h4>Total Runs</h4>
3608
+ <div class="value">\${totalRuns}</div>
3609
+ </div>
3610
+ <div class="history-stat-item">
3611
+ <h4>Avg Duration</h4>
3612
+ <div class="value">\${formatDuration(avgDuration)}</div>
3613
+ </div>
3614
+ <div class="history-stat-item">
3615
+ <h4>Avg Tests</h4>
3616
+ <div class="value">\${avgTests}</div>
3617
+ </div>
3618
+ <div class="history-stat-item">
3619
+ <h4>Avg Pass Rate</h4>
3620
+ <div class="value">\${avgPassRate.toFixed(1)}%</div>
3621
+ </div>
3622
+ </div>
3623
+ \`;
3624
+ }
3625
+
3626
+ // Render timeline
3627
+ const timelineContainer = document.getElementById('historyTimeline');
3628
+ if (timelineContainer) {
3629
+ const recentHistory = history.slice(-10).reverse(); // Last 10 runs, most recent first
3630
+ timelineContainer.innerHTML = '<h3 style="margin: 0 0 1rem 0; color: #2c3e50;">Recent Execution Timeline</h3>' +
3631
+ recentHistory.map(run => {
3632
+ const timestamp = new Date(run.timestamp);
3633
+ const timeStr = timestamp.toLocaleString();
3634
+ const total = run.stats.tests || 0;
3635
+ const passed = run.stats.passes || 0;
3636
+ const failed = run.stats.failures || 0;
3637
+ const badgeClass = failed > 0 ? 'failure' : 'success';
3638
+ const badgeText = failed > 0 ? \`\${failed} Failed\` : \`All Passed\`;
3639
+
3640
+ return \`
3641
+ <div class="timeline-item">
3642
+ <div class="timeline-time">\${timeStr}</div>
3643
+ <div class="timeline-result">
3644
+ <span class="timeline-badge \${badgeClass}">\${badgeText}</span>
3645
+ <span>\${passed}/\${total} passed</span>
3646
+ <span>·</span>
3647
+ <span>\${formatDuration(run.duration || 0)}</span>
3648
+ </div>
3649
+ </div>
3650
+ \`;
3651
+ }).join('');
3652
+ }
3653
+ }
3654
+
3655
+ // Helper to parse duration text to milliseconds
3656
+ function parseDuration(durationText) {
3657
+ if (!durationText) return 0;
3658
+ const match = durationText.match(/(\\d+(?:\\.\\d+)?)(ms|s|m)/);
3659
+ if (!match) return 0;
3660
+ const value = parseFloat(match[1]);
3661
+ const unit = match[2];
3662
+ if (unit === 'ms') return value;
3663
+ if (unit === 's') return value * 1000;
3664
+ if (unit === 'm') return value * 60000;
3665
+ return 0;
3666
+ }
3667
+
3668
+ // Helper to format duration
3669
+ function formatDuration(ms) {
3670
+ if (ms < 1000) return Math.round(ms) + 'ms';
3671
+ if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
3672
+ return (ms / 60000).toFixed(2) + 'm';
3673
+ }
2953
3674
  `
2954
3675
  }
2955
3676
  }