codeceptjs 3.7.6-beta.4 → 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.
@@ -183,12 +183,21 @@ module.exports = function (config) {
183
183
  if (hook.htmlReporterStartTime) {
184
184
  hook.duration = Date.now() - hook.htmlReporterStartTime
185
185
  }
186
+ // Enhanced hook info: include type, name, location, error, and context
186
187
  const hookInfo = {
187
188
  title: hook.title,
188
189
  type: hook.type || 'unknown', // before, after, beforeSuite, afterSuite
189
190
  status: hook.err ? 'failed' : 'passed',
190
191
  duration: hook.duration || 0,
191
- 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,
192
201
  }
193
202
  currentTestHooks.push(hookInfo)
194
203
  reportData.hooks.push(hookInfo)
@@ -212,6 +221,43 @@ module.exports = function (config) {
212
221
  })
213
222
  })
214
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
+
215
261
  // Collect test results
216
262
  event.dispatcher.on(event.test.finished, test => {
217
263
  const testId = generateTestId(test)
@@ -373,8 +419,14 @@ module.exports = function (config) {
373
419
  // Calculate stats from our collected test data instead of using result.stats
374
420
  const passedTests = reportData.tests.filter(t => t.state === 'passed').length
375
421
  const failedTests = reportData.tests.filter(t => t.state === 'failed').length
376
- const pendingTests = reportData.tests.filter(t => t.state === 'pending').length
377
- 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)
378
430
 
379
431
  // Populate failures from our collected test data with enhanced details
380
432
  reportData.failures = reportData.tests
@@ -418,9 +470,10 @@ module.exports = function (config) {
418
470
  passes: passedTests,
419
471
  failures: failedTests,
420
472
  pending: pendingTests,
421
- skipped: skippedTests,
422
473
  duration: reportData.duration,
423
474
  failedHooks: result.stats?.failedHooks || 0,
475
+ flaky: flakyTests,
476
+ artifacts: totalArtifacts,
424
477
  }
425
478
 
426
479
  // Debug logging for final stats
@@ -803,6 +856,10 @@ module.exports = function (config) {
803
856
  try {
804
857
  const workerData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
805
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
+
806
863
  // Merge stats
807
864
  if (workerData.stats) {
808
865
  consolidatedData.stats.passes += workerData.stats.passes || 0
@@ -814,8 +871,14 @@ module.exports = function (config) {
814
871
  consolidatedData.stats.failedHooks += workerData.stats.failedHooks || 0
815
872
  }
816
873
 
817
- // Merge tests and failures
818
- 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
+ }
819
882
  if (workerData.failures) consolidatedData.failures.push(...workerData.failures)
820
883
  if (workerData.hooks) consolidatedData.hooks.push(...workerData.hooks)
821
884
  if (workerData.retries) consolidatedData.retries.push(...workerData.retries)
@@ -932,6 +995,10 @@ module.exports = function (config) {
932
995
  const failed = stats.failures || 0
933
996
  const pending = stats.pending || 0
934
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'
935
1002
 
936
1003
  return `
937
1004
  <div class="stats-cards">
@@ -948,9 +1015,21 @@ module.exports = function (config) {
948
1015
  <span class="stat-number">${failed}</span>
949
1016
  </div>
950
1017
  <div class="stat-card pending">
951
- <h3>Pending</h3>
1018
+ <h3>Skipped</h3>
952
1019
  <span class="stat-number">${pending}</span>
953
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>
954
1033
  </div>
955
1034
  <div class="pie-chart-container">
956
1035
  <canvas id="statsChart" width="300" height="300"></canvas>
@@ -971,49 +1050,73 @@ module.exports = function (config) {
971
1050
  return '<p>No tests found.</p>'
972
1051
  }
973
1052
 
974
- return tests
975
- .map(test => {
976
- const statusClass = test.state || 'unknown'
977
- // Use preserved parent/suite titles (for worker mode) or fallback to direct access
978
- const feature = test.isBdd && test.feature ? test.feature.name : test.parentTitle || test.suiteTitle || test.parent?.title || test.suite?.title || 'Unknown Feature'
979
- // Always try to show steps if available, even for unknown feature
980
- const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : ''
981
- const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : ''
982
- const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : ''
983
- const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts, test.state === 'failed') : ''
984
- const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : ''
985
- const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : ''
986
- const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts) : ''
987
- 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
+ })
988
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()
989
1065
  return `
990
- <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'}">
991
- <div class="test-header" onclick="toggleTestDetails('test-${test.id}')">
992
- <span class="test-status ${statusClass}">●</span>
993
- <div class="test-info">
994
- <h3 class="test-title">${test.isBdd ? `Scenario: ${test.title}` : test.title}</h3>
995
- <div class="test-meta-line">
996
- <span class="test-feature">${test.isBdd ? 'Feature: ' : ''}${feature}</span>
997
- ${test.uid ? `<span class="test-uid">${test.uid}</span>` : ''}
998
- <span class="test-duration">${formatDuration(test.duration)}</span>
999
- ${test.retryAttempts > 0 ? `<span class="retry-badge">${test.retryAttempts} retries</span>` : ''}
1000
- ${test.isBdd ? '<span class="bdd-badge">Gherkin</span>' : ''}
1001
- </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('')}
1002
1117
  </div>
1003
- </div>
1004
- <div class="test-details" id="details-test-${test.id}">
1005
- ${test.err ? `<div class="error-message"><pre>${escapeHtml(getErrorMessage(test))}</pre></div>` : ''}
1006
- ${featureDetails}
1007
- ${tags}
1008
- ${metadata}
1009
- ${retries}
1010
- ${notes}
1011
- ${hooks}
1012
- ${steps}
1013
- ${artifacts}
1014
- </div>
1015
- </div>
1016
- `
1118
+ </section>
1119
+ `
1017
1120
  })
1018
1121
  .join('')
1019
1122
  }
@@ -1103,13 +1206,19 @@ module.exports = function (config) {
1103
1206
  const statusClass = hook.status || 'unknown'
1104
1207
  const hookType = hook.type || 'hook'
1105
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>` : ''
1106
1211
 
1107
1212
  return `
1108
1213
  <div class="hook-item ${statusClass}">
1109
1214
  <span class="hook-status ${statusClass}">●</span>
1110
- <span class="hook-title">${hookType}: ${hookTitle}</span>
1111
- <span class="hook-duration">${formatDuration(hook.duration)}</span>
1112
- ${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>
1113
1222
  </div>
1114
1223
  `
1115
1224
  })
@@ -1169,12 +1278,21 @@ module.exports = function (config) {
1169
1278
  `
1170
1279
  }
1171
1280
 
1172
- 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
+
1173
1285
  return `
1174
1286
  <div class="retry-section">
1175
- <h4>Retry Information:</h4>
1287
+ <h4>Retry History:</h4>
1176
1288
  <div class="retry-info">
1177
- <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>
1178
1296
  </div>
1179
1297
  </div>
1180
1298
  `
@@ -1420,6 +1538,30 @@ module.exports = function (config) {
1420
1538
 
1421
1539
  function escapeHtml(text) {
1422
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
+ }
1423
1565
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
1424
1566
  }
1425
1567
 
@@ -1535,8 +1677,12 @@ module.exports = function (config) {
1535
1677
  if (!systemInfo) return ''
1536
1678
 
1537
1679
  const formatInfo = (key, value) => {
1680
+ // Handle array values (e.g., ['Node', '22.14.0', 'path'])
1538
1681
  if (Array.isArray(value) && value.length > 1) {
1539
- 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>`
1540
1686
  } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') {
1541
1687
  return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value)}</span></div>`
1542
1688
  }
@@ -1578,30 +1724,46 @@ module.exports = function (config) {
1578
1724
  <!DOCTYPE html>
1579
1725
  <html lang="en">
1580
1726
  <head>
1581
- <meta charset="UTF-8">
1582
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1583
- <title>{{title}}</title>
1584
- <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>
1585
1731
  </head>
1586
1732
  <body>
1587
- <header class="report-header">
1588
- <h1>{{title}}</h1>
1589
- <div class="report-meta">
1590
- <span>Generated: {{timestamp}}</span>
1591
- <span>Duration: {{duration}}</span>
1592
- </div>
1593
- </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>
1594
1740
 
1595
- <main class="report-content">
1596
- {{systemInfoHtml}}
1741
+ <main class="report-content">
1742
+ {{systemInfoHtml}}
1597
1743
 
1598
- <section class="stats-section">
1599
- <h2>Test Statistics</h2>
1600
- {{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>
1601
1761
  </section>
1602
1762
 
1603
1763
  <section class="history-section" style="display: {{showHistory}};">
1604
- <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>
1605
1767
  <div class="history-chart-container">
1606
1768
  <canvas id="historyChart" width="1600" height="600"></canvas>
1607
1769
  </div>
@@ -1654,10 +1816,10 @@ module.exports = function (config) {
1654
1816
  </div>
1655
1817
  </section>
1656
1818
 
1657
- <section class="retries-section" style="display: {{showRetries}};">
1658
- <h2>Test Retries</h2>
1819
+ <section class="retries-section" style="display: none;">
1820
+ <h2>Test Retries (Moved to Test Details)</h2>
1659
1821
  <div class="retries-container">
1660
- {{retriesHtml}}
1822
+ <p>Retry information is now shown in each test's details section.</p>
1661
1823
  </div>
1662
1824
  </section>
1663
1825
 
@@ -1722,7 +1884,7 @@ body {
1722
1884
  padding: 0 1rem;
1723
1885
  }
1724
1886
 
1725
- .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 {
1726
1888
  background: white;
1727
1889
  margin-bottom: 2rem;
1728
1890
  border-radius: 8px;
@@ -1730,7 +1892,7 @@ body {
1730
1892
  overflow: hidden;
1731
1893
  }
1732
1894
 
1733
- .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 {
1734
1896
  background: #34495e;
1735
1897
  color: white;
1736
1898
  padding: 1rem;
@@ -1757,6 +1919,23 @@ body {
1757
1919
  .stat-card.passed { background: #27ae60; }
1758
1920
  .stat-card.failed { background: #e74c3c; }
1759
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
+ }
1760
1939
 
1761
1940
  .stat-card h3 {
1762
1941
  font-size: 0.9rem;
@@ -1784,6 +1963,54 @@ body {
1784
1963
  height: auto;
1785
1964
  }
1786
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
+
1787
2014
  .test-item {
1788
2015
  border-bottom: 1px solid #eee;
1789
2016
  margin: 0;
@@ -1861,9 +2088,64 @@ body {
1861
2088
  font-weight: bold;
1862
2089
  }
1863
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
+
1864
2141
  .test-duration {
1865
- font-size: 0.8rem;
1866
- 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;
1867
2149
  }
1868
2150
 
1869
2151
  .test-details {
@@ -1901,27 +2183,39 @@ body {
1901
2183
 
1902
2184
  .hook-item {
1903
2185
  display: flex;
1904
- align-items: center;
1905
- padding: 0.5rem 0;
1906
- 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;
1907
2192
  }
1908
2193
 
1909
2194
  .hook-item:last-child {
1910
- border-bottom: none;
2195
+ margin-bottom: 0;
1911
2196
  }
1912
2197
 
1913
2198
  .hook-status {
1914
- margin-right: 0.5rem;
2199
+ margin-right: 0.75rem;
2200
+ flex-shrink: 0;
2201
+ margin-top: 0.2rem;
1915
2202
  }
1916
2203
 
1917
2204
  .hook-status.passed { color: #27ae60; }
1918
2205
  .hook-status.failed { color: #e74c3c; }
1919
2206
 
1920
- .hook-title {
2207
+ .hook-content {
1921
2208
  flex: 1;
2209
+ display: flex;
2210
+ flex-direction: column;
2211
+ gap: 0.25rem;
2212
+ }
2213
+
2214
+ .hook-title {
1922
2215
  font-family: 'Courier New', monospace;
1923
2216
  font-size: 0.9rem;
1924
2217
  font-weight: bold;
2218
+ color: #2c3e50;
1925
2219
  }
1926
2220
 
1927
2221
  .hook-duration {
@@ -1929,8 +2223,13 @@ body {
1929
2223
  color: #7f8c8d;
1930
2224
  }
1931
2225
 
2226
+ .hook-location, .hook-context {
2227
+ font-size: 0.8rem;
2228
+ color: #6c757d;
2229
+ font-style: italic;
2230
+ }
2231
+
1932
2232
  .hook-error {
1933
- width: 100%;
1934
2233
  margin-top: 0.5rem;
1935
2234
  padding: 0.5rem;
1936
2235
  background: #fee;
@@ -2219,11 +2518,22 @@ body {
2219
2518
  }
2220
2519
 
2221
2520
  /* Retry Information */
2521
+ .retry-section {
2522
+ margin-top: 1rem;
2523
+ }
2524
+
2222
2525
  .retry-info {
2223
- padding: 0.5rem;
2224
- background: #fef9e7;
2526
+ padding: 1rem;
2527
+ background: #fff9e6;
2225
2528
  border-radius: 4px;
2226
- 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;
2227
2537
  }
2228
2538
 
2229
2539
  .retry-count {
@@ -2231,6 +2541,29 @@ body {
2231
2541
  font-weight: 500;
2232
2542
  }
2233
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
+
2234
2567
  /* Retries Section */
2235
2568
  .retry-item {
2236
2569
  padding: 1rem;
@@ -2276,6 +2609,92 @@ body {
2276
2609
  }
2277
2610
 
2278
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
+
2279
2698
  .history-chart-container {
2280
2699
  padding: 2rem 1rem;
2281
2700
  display: flex;
@@ -2287,6 +2706,87 @@ body {
2287
2706
  height: auto;
2288
2707
  }
2289
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
+
2290
2790
  /* Hidden items for filtering */
2291
2791
  .test-item.filtered-out {
2292
2792
  display: none !important;
@@ -2508,6 +3008,21 @@ body {
2508
3008
  function scrollToTop() {
2509
3009
  window.scrollTo({ top: 0, behavior: 'smooth' });
2510
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
+
2511
3026
  function toggleTestDetails(testId) {
2512
3027
  const details = document.getElementById('details-' + testId);
2513
3028
  if (details.style.display === 'none' || details.style.display === '') {
@@ -2993,6 +3508,9 @@ document.addEventListener('DOMContentLoaded', function() {
2993
3508
  // Draw charts
2994
3509
  drawPieChart();
2995
3510
  drawHistoryChart();
3511
+ renderTestPerformance();
3512
+ renderHistoryTimeline();
3513
+
2996
3514
  // Add Go to Top button
2997
3515
  const goTopBtn = document.createElement('button');
2998
3516
  goTopBtn.innerText = '↑ Top';
@@ -3018,6 +3536,141 @@ document.addEventListener('DOMContentLoaded', function() {
3018
3536
  document.getElementById('retryFilter').addEventListener('change', applyFilters);
3019
3537
  document.getElementById('typeFilter').addEventListener('change', applyFilters);
3020
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
+ }
3021
3674
  `
3022
3675
  }
3023
3676
  }