codeceptjs 4.0.0-beta.2 → 4.0.0-beta.21

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.
Files changed (209) hide show
  1. package/README.md +133 -120
  2. package/bin/codecept.js +107 -96
  3. package/bin/test-server.js +64 -0
  4. package/docs/webapi/clearCookie.mustache +1 -1
  5. package/docs/webapi/click.mustache +5 -1
  6. package/lib/actor.js +73 -103
  7. package/lib/ai.js +159 -188
  8. package/lib/assert/empty.js +22 -24
  9. package/lib/assert/equal.js +30 -37
  10. package/lib/assert/error.js +14 -14
  11. package/lib/assert/include.js +43 -48
  12. package/lib/assert/throws.js +11 -11
  13. package/lib/assert/truth.js +22 -22
  14. package/lib/assert.js +20 -18
  15. package/lib/codecept.js +262 -162
  16. package/lib/colorUtils.js +50 -52
  17. package/lib/command/check.js +206 -0
  18. package/lib/command/configMigrate.js +56 -51
  19. package/lib/command/definitions.js +96 -109
  20. package/lib/command/dryRun.js +77 -79
  21. package/lib/command/generate.js +234 -194
  22. package/lib/command/gherkin/init.js +42 -33
  23. package/lib/command/gherkin/snippets.js +76 -74
  24. package/lib/command/gherkin/steps.js +20 -17
  25. package/lib/command/info.js +74 -38
  26. package/lib/command/init.js +301 -290
  27. package/lib/command/interactive.js +41 -32
  28. package/lib/command/list.js +28 -27
  29. package/lib/command/run-multiple/chunk.js +51 -48
  30. package/lib/command/run-multiple/collection.js +5 -5
  31. package/lib/command/run-multiple/run.js +5 -1
  32. package/lib/command/run-multiple.js +97 -97
  33. package/lib/command/run-rerun.js +19 -25
  34. package/lib/command/run-workers.js +68 -92
  35. package/lib/command/run.js +39 -27
  36. package/lib/command/utils.js +80 -64
  37. package/lib/command/workers/runTests.js +388 -226
  38. package/lib/config.js +109 -50
  39. package/lib/container.js +765 -261
  40. package/lib/data/context.js +60 -61
  41. package/lib/data/dataScenarioConfig.js +47 -47
  42. package/lib/data/dataTableArgument.js +32 -32
  43. package/lib/data/table.js +22 -22
  44. package/lib/effects.js +307 -0
  45. package/lib/element/WebElement.js +327 -0
  46. package/lib/els.js +160 -0
  47. package/lib/event.js +173 -163
  48. package/lib/globals.js +141 -0
  49. package/lib/heal.js +89 -85
  50. package/lib/helper/AI.js +131 -41
  51. package/lib/helper/ApiDataFactory.js +107 -75
  52. package/lib/helper/Appium.js +542 -404
  53. package/lib/helper/FileSystem.js +100 -79
  54. package/lib/helper/GraphQL.js +44 -43
  55. package/lib/helper/GraphQLDataFactory.js +52 -52
  56. package/lib/helper/JSONResponse.js +126 -88
  57. package/lib/helper/Mochawesome.js +54 -29
  58. package/lib/helper/Playwright.js +2547 -1316
  59. package/lib/helper/Puppeteer.js +1578 -1181
  60. package/lib/helper/REST.js +209 -68
  61. package/lib/helper/WebDriver.js +1482 -1342
  62. package/lib/helper/errors/ConnectionRefused.js +6 -6
  63. package/lib/helper/errors/ElementAssertion.js +11 -16
  64. package/lib/helper/errors/ElementNotFound.js +5 -9
  65. package/lib/helper/errors/RemoteBrowserConnectionRefused.js +5 -5
  66. package/lib/helper/extras/Console.js +11 -11
  67. package/lib/helper/extras/PlaywrightLocator.js +110 -0
  68. package/lib/helper/extras/PlaywrightPropEngine.js +18 -18
  69. package/lib/helper/extras/PlaywrightReactVueLocator.js +17 -8
  70. package/lib/helper/extras/PlaywrightRestartOpts.js +25 -11
  71. package/lib/helper/extras/Popup.js +22 -22
  72. package/lib/helper/extras/React.js +27 -28
  73. package/lib/helper/network/actions.js +36 -42
  74. package/lib/helper/network/utils.js +78 -84
  75. package/lib/helper/scripts/blurElement.js +5 -5
  76. package/lib/helper/scripts/focusElement.js +5 -5
  77. package/lib/helper/scripts/highlightElement.js +8 -8
  78. package/lib/helper/scripts/isElementClickable.js +34 -34
  79. package/lib/helper.js +2 -3
  80. package/lib/history.js +23 -19
  81. package/lib/hooks.js +8 -8
  82. package/lib/html.js +94 -104
  83. package/lib/index.js +38 -27
  84. package/lib/listener/config.js +30 -23
  85. package/lib/listener/emptyRun.js +54 -0
  86. package/lib/listener/enhancedGlobalRetry.js +110 -0
  87. package/lib/listener/exit.js +16 -18
  88. package/lib/listener/globalRetry.js +70 -0
  89. package/lib/listener/globalTimeout.js +181 -0
  90. package/lib/listener/helpers.js +76 -51
  91. package/lib/listener/mocha.js +10 -11
  92. package/lib/listener/result.js +11 -0
  93. package/lib/listener/retryEnhancer.js +85 -0
  94. package/lib/listener/steps.js +71 -59
  95. package/lib/listener/store.js +20 -0
  96. package/lib/locator.js +214 -197
  97. package/lib/mocha/asyncWrapper.js +274 -0
  98. package/lib/mocha/bdd.js +167 -0
  99. package/lib/mocha/cli.js +341 -0
  100. package/lib/mocha/factory.js +163 -0
  101. package/lib/mocha/featureConfig.js +89 -0
  102. package/lib/mocha/gherkin.js +231 -0
  103. package/lib/mocha/hooks.js +121 -0
  104. package/lib/mocha/index.js +21 -0
  105. package/lib/mocha/inject.js +46 -0
  106. package/lib/{interfaces → mocha}/scenarioConfig.js +58 -34
  107. package/lib/mocha/suite.js +89 -0
  108. package/lib/mocha/test.js +184 -0
  109. package/lib/mocha/types.d.ts +42 -0
  110. package/lib/mocha/ui.js +242 -0
  111. package/lib/output.js +141 -71
  112. package/lib/parser.js +54 -44
  113. package/lib/pause.js +173 -145
  114. package/lib/plugin/analyze.js +403 -0
  115. package/lib/plugin/{autoLogin.js → auth.js} +178 -79
  116. package/lib/plugin/autoDelay.js +36 -40
  117. package/lib/plugin/coverage.js +131 -78
  118. package/lib/plugin/customLocator.js +22 -21
  119. package/lib/plugin/customReporter.js +53 -0
  120. package/lib/plugin/enhancedRetryFailedStep.js +99 -0
  121. package/lib/plugin/heal.js +101 -110
  122. package/lib/plugin/htmlReporter.js +3648 -0
  123. package/lib/plugin/pageInfo.js +140 -0
  124. package/lib/plugin/pauseOnFail.js +12 -11
  125. package/lib/plugin/retryFailedStep.js +82 -47
  126. package/lib/plugin/screenshotOnFail.js +111 -92
  127. package/lib/plugin/stepByStepReport.js +159 -101
  128. package/lib/plugin/stepTimeout.js +20 -25
  129. package/lib/plugin/subtitles.js +38 -38
  130. package/lib/recorder.js +193 -130
  131. package/lib/rerun.js +94 -49
  132. package/lib/result.js +238 -0
  133. package/lib/retryCoordinator.js +207 -0
  134. package/lib/secret.js +20 -18
  135. package/lib/session.js +95 -89
  136. package/lib/step/base.js +239 -0
  137. package/lib/step/comment.js +10 -0
  138. package/lib/step/config.js +50 -0
  139. package/lib/step/func.js +46 -0
  140. package/lib/step/helper.js +50 -0
  141. package/lib/step/meta.js +99 -0
  142. package/lib/step/record.js +74 -0
  143. package/lib/step/retry.js +11 -0
  144. package/lib/step/section.js +55 -0
  145. package/lib/step.js +18 -329
  146. package/lib/steps.js +54 -0
  147. package/lib/store.js +38 -7
  148. package/lib/template/heal.js +3 -12
  149. package/lib/template/prompts/generatePageObject.js +31 -0
  150. package/lib/template/prompts/healStep.js +13 -0
  151. package/lib/template/prompts/writeStep.js +9 -0
  152. package/lib/test-server.js +334 -0
  153. package/lib/timeout.js +60 -0
  154. package/lib/transform.js +8 -8
  155. package/lib/translation.js +34 -21
  156. package/lib/utils/loaderCheck.js +124 -0
  157. package/lib/utils/mask_data.js +47 -0
  158. package/lib/utils/typescript.js +237 -0
  159. package/lib/utils.js +411 -228
  160. package/lib/workerStorage.js +37 -34
  161. package/lib/workers.js +532 -296
  162. package/package.json +124 -95
  163. package/translations/de-DE.js +5 -3
  164. package/translations/fr-FR.js +5 -4
  165. package/translations/index.js +22 -12
  166. package/translations/it-IT.js +4 -3
  167. package/translations/ja-JP.js +4 -3
  168. package/translations/nl-NL.js +76 -0
  169. package/translations/pl-PL.js +4 -3
  170. package/translations/pt-BR.js +4 -3
  171. package/translations/ru-RU.js +4 -3
  172. package/translations/utils.js +10 -0
  173. package/translations/zh-CN.js +4 -3
  174. package/translations/zh-TW.js +4 -3
  175. package/typings/index.d.ts +546 -185
  176. package/typings/promiseBasedTypes.d.ts +150 -875
  177. package/typings/types.d.ts +547 -992
  178. package/lib/cli.js +0 -249
  179. package/lib/dirname.js +0 -5
  180. package/lib/helper/Expect.js +0 -425
  181. package/lib/helper/ExpectHelper.js +0 -399
  182. package/lib/helper/MockServer.js +0 -223
  183. package/lib/helper/Nightmare.js +0 -1411
  184. package/lib/helper/Protractor.js +0 -1835
  185. package/lib/helper/SoftExpectHelper.js +0 -381
  186. package/lib/helper/TestCafe.js +0 -1410
  187. package/lib/helper/clientscripts/nightmare.js +0 -213
  188. package/lib/helper/testcafe/testControllerHolder.js +0 -42
  189. package/lib/helper/testcafe/testcafe-utils.js +0 -63
  190. package/lib/interfaces/bdd.js +0 -98
  191. package/lib/interfaces/featureConfig.js +0 -69
  192. package/lib/interfaces/gherkin.js +0 -195
  193. package/lib/listener/artifacts.js +0 -19
  194. package/lib/listener/retry.js +0 -68
  195. package/lib/listener/timeout.js +0 -109
  196. package/lib/mochaFactory.js +0 -110
  197. package/lib/plugin/allure.js +0 -15
  198. package/lib/plugin/commentStep.js +0 -136
  199. package/lib/plugin/debugErrors.js +0 -67
  200. package/lib/plugin/eachElement.js +0 -127
  201. package/lib/plugin/fakerTransform.js +0 -49
  202. package/lib/plugin/retryTo.js +0 -121
  203. package/lib/plugin/selenoid.js +0 -371
  204. package/lib/plugin/standardActingHelpers.js +0 -9
  205. package/lib/plugin/tryTo.js +0 -105
  206. package/lib/plugin/wdio.js +0 -246
  207. package/lib/scenario.js +0 -222
  208. package/lib/ui.js +0 -238
  209. package/lib/within.js +0 -70
@@ -0,0 +1,3648 @@
1
+ // @ts-nocheck
2
+ // TypeScript: Import Node.js types for process, fs, path, etc.
3
+ /// <reference types="node" />
4
+
5
+ import fs from 'fs'
6
+ import path from 'path'
7
+ import { mkdirp } from 'mkdirp'
8
+ import crypto from 'crypto'
9
+ import { threadId } from 'worker_threads'
10
+ import { template } from '../utils.js'
11
+ import { getMachineInfo } from '../command/info.js'
12
+
13
+ import event from '../event.js'
14
+ import output from '../output.js'
15
+ import Codecept from '../codecept.js'
16
+
17
+ const defaultConfig = {
18
+ output: typeof global !== 'undefined' && global.output_dir ? global.output_dir : './output',
19
+ reportFileName: 'report.html',
20
+ includeArtifacts: true,
21
+ showSteps: true,
22
+ showSkipped: true,
23
+ showMetadata: true,
24
+ showTags: true,
25
+ showRetries: true,
26
+ exportStats: false,
27
+ exportStatsPath: './stats.json',
28
+ keepHistory: false,
29
+ historyPath: './test-history.json',
30
+ maxHistoryEntries: 50,
31
+ }
32
+
33
+ /**
34
+ * HTML Reporter Plugin for CodeceptJS
35
+ *
36
+ * Generates comprehensive HTML reports showing:
37
+ * - Test statistics
38
+ * - Feature/Scenario details
39
+ * - Individual step results
40
+ * - Test artifacts (screenshots, etc.)
41
+ *
42
+ * ## Configuration
43
+ *
44
+ * ```js
45
+ * "plugins": {
46
+ * "htmlReporter": {
47
+ * "enabled": true,
48
+ * "output": "./output",
49
+ * "reportFileName": "report.html",
50
+ * "includeArtifacts": true,
51
+ * "showSteps": true,
52
+ * "showSkipped": true,
53
+ * "showMetadata": true,
54
+ * "showTags": true,
55
+ * "showRetries": true,
56
+ * "exportStats": false,
57
+ * "exportStatsPath": "./stats.json",
58
+ * "keepHistory": false,
59
+ * "historyPath": "./test-history.json",
60
+ * "maxHistoryEntries": 50
61
+ * }
62
+ * }
63
+ * ```
64
+ */
65
+ export default function (config) {
66
+ const options = { ...defaultConfig, ...config }
67
+ /**
68
+ * TypeScript: Explicitly type reportData arrays as any[] to avoid 'never' errors
69
+ */
70
+ let reportData = {
71
+ stats: {},
72
+ tests: [],
73
+ failures: [],
74
+ hooks: [],
75
+ startTime: null,
76
+ endTime: null,
77
+ retries: [],
78
+ config: options,
79
+ }
80
+ let currentTestSteps = []
81
+ let currentTestHooks = []
82
+ let currentBddSteps = [] // Track BDD/Gherkin steps
83
+ let testRetryAttempts = new Map() // Track retry attempts per test
84
+ let currentSuite = null // Track current suite for BDD detection
85
+
86
+ // Initialize report directory
87
+ const reportDir = options.output ? path.resolve(global.codecept_dir, options.output) : path.resolve(global.output_dir || './output')
88
+ mkdirp.sync(reportDir)
89
+
90
+ // Track overall test execution
91
+ event.dispatcher.on(event.all.before, () => {
92
+ reportData.startTime = new Date().toISOString()
93
+ output.print('HTML Reporter: Starting HTML report generation...')
94
+ })
95
+
96
+ // Track test start to initialize steps and hooks collection
97
+ event.dispatcher.on(event.test.before, test => {
98
+ currentTestSteps = []
99
+ currentTestHooks = []
100
+ currentBddSteps = []
101
+
102
+ // Track current suite for BDD detection
103
+ currentSuite = test.parent
104
+
105
+ // Enhanced retry detection with priority-based approach
106
+ const testId = generateTestId(test)
107
+
108
+ // Only set retry count if not already set, using priority order
109
+ if (!testRetryAttempts.has(testId)) {
110
+ // Method 1: Check retryNum property (most reliable)
111
+ if (test.retryNum && test.retryNum > 0) {
112
+ testRetryAttempts.set(testId, test.retryNum)
113
+ output.debug(`HTML Reporter: Retry count detected (retryNum) for ${test.title}, attempts: ${test.retryNum}`)
114
+ }
115
+ // Method 2: Check currentRetry property
116
+ else if (test.currentRetry && test.currentRetry > 0) {
117
+ testRetryAttempts.set(testId, test.currentRetry)
118
+ output.debug(`HTML Reporter: Retry count detected (currentRetry) for ${test.title}, attempts: ${test.currentRetry}`)
119
+ }
120
+ // Method 3: Check if this is a retried test
121
+ else if (test.retriedTest && test.retriedTest()) {
122
+ const originalTest = test.retriedTest()
123
+ const originalTestId = generateTestId(originalTest)
124
+ if (!testRetryAttempts.has(originalTestId)) {
125
+ testRetryAttempts.set(originalTestId, 1) // Start with 1 retry
126
+ } else {
127
+ testRetryAttempts.set(originalTestId, testRetryAttempts.get(originalTestId) + 1)
128
+ }
129
+ output.debug(`HTML Reporter: Retry detected (retriedTest) for ${originalTest.title}, attempts: ${testRetryAttempts.get(originalTestId)}`)
130
+ }
131
+ // Method 4: Check if test has been seen before (indicating a retry)
132
+ else if (reportData.tests.some(t => t.id === testId)) {
133
+ testRetryAttempts.set(testId, 1) // First retry detected
134
+ output.debug(`HTML Reporter: Retry detected (duplicate test) for ${test.title}, attempts: 1`)
135
+ }
136
+ }
137
+ })
138
+
139
+ // Collect step information
140
+ event.dispatcher.on(event.step.started, step => {
141
+ step.htmlReporterStartTime = Date.now()
142
+ })
143
+
144
+ event.dispatcher.on(event.step.finished, step => {
145
+ if (step.htmlReporterStartTime) {
146
+ step.htmlReporterDuration = Date.now() - step.htmlReporterStartTime
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
+
168
+ currentTestSteps.push({
169
+ name: step.name,
170
+ actor: step.actor,
171
+ args: serializedArgs,
172
+ status: step.failed ? 'failed' : 'success',
173
+ duration: step.htmlReporterDuration || step.duration || 0,
174
+ })
175
+ })
176
+
177
+ // Collect hook information
178
+ event.dispatcher.on(event.hook.started, hook => {
179
+ hook.htmlReporterStartTime = Date.now()
180
+ })
181
+
182
+ event.dispatcher.on(event.hook.finished, hook => {
183
+ if (hook.htmlReporterStartTime) {
184
+ hook.duration = Date.now() - hook.htmlReporterStartTime
185
+ }
186
+ // Enhanced hook info: include type, name, location, error, and context
187
+ const hookInfo = {
188
+ title: hook.title,
189
+ type: hook.type || 'unknown', // before, after, beforeSuite, afterSuite
190
+ status: hook.err ? 'failed' : 'passed',
191
+ duration: hook.duration || 0,
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,
201
+ }
202
+ currentTestHooks.push(hookInfo)
203
+ reportData.hooks.push(hookInfo)
204
+ })
205
+
206
+ // Collect BDD/Gherkin step information
207
+ event.dispatcher.on(event.bddStep.started, step => {
208
+ step.htmlReporterStartTime = Date.now()
209
+ })
210
+
211
+ event.dispatcher.on(event.bddStep.finished, step => {
212
+ if (step.htmlReporterStartTime) {
213
+ step.htmlReporterDuration = Date.now() - step.htmlReporterStartTime
214
+ }
215
+ currentBddSteps.push({
216
+ keyword: step.actor || 'Given',
217
+ text: step.name,
218
+ status: step.failed ? 'failed' : 'success',
219
+ duration: step.htmlReporterDuration || step.duration || 0,
220
+ comment: step.comment,
221
+ })
222
+ })
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
+
261
+ // Collect test results
262
+ event.dispatcher.on(event.test.finished, test => {
263
+ const testId = generateTestId(test)
264
+ let retryAttempts = testRetryAttempts.get(testId) || 0
265
+
266
+ // Additional retry detection in test.finished event
267
+ // Check if this test has retry indicators we might have missed
268
+ if (retryAttempts === 0) {
269
+ if (test.retryNum && test.retryNum > 0) {
270
+ retryAttempts = test.retryNum
271
+ testRetryAttempts.set(testId, retryAttempts)
272
+ output.debug(`HTML Reporter: Late retry detection (retryNum) for ${test.title}, attempts: ${retryAttempts}`)
273
+ } else if (test.currentRetry && test.currentRetry > 0) {
274
+ retryAttempts = test.currentRetry
275
+ testRetryAttempts.set(testId, retryAttempts)
276
+ output.debug(`HTML Reporter: Late retry detection (currentRetry) for ${test.title}, attempts: ${retryAttempts}`)
277
+ } else if (test._retries && test._retries > 0) {
278
+ retryAttempts = test._retries
279
+ testRetryAttempts.set(testId, retryAttempts)
280
+ output.debug(`HTML Reporter: Late retry detection (_retries) for ${test.title}, attempts: ${retryAttempts}`)
281
+ }
282
+ }
283
+
284
+ // Debug logging
285
+ output.debug(`HTML Reporter: Test finished - ${test.title}, State: ${test.state}, Retries: ${retryAttempts}`)
286
+
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)
290
+ const steps = isBddTest ? currentBddSteps : currentTestSteps
291
+ const featureInfo = isBddTest ? getBddFeatureInfo(test, suite) : null
292
+
293
+ // Check if this test already exists in reportData.tests (from a previous retry)
294
+ const existingTestIndex = reportData.tests.findIndex(t => t.id === testId)
295
+ const hasFailedBefore = existingTestIndex >= 0 && reportData.tests[existingTestIndex] && reportData.tests[existingTestIndex].state === 'failed'
296
+ const currentlyFailed = test.state === 'failed'
297
+
298
+ // Debug artifacts collection (but don't process them yet - screenshots may not be ready)
299
+ output.debug(`HTML Reporter: Test ${test.title} artifacts at test.finished: ${JSON.stringify(test.artifacts)}`)
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
+
306
+ const testData = {
307
+ ...test,
308
+ id: testId,
309
+ duration: test.duration || 0,
310
+ steps: [...steps], // Copy the steps (BDD or regular)
311
+ hooks: [...currentTestHooks], // Copy the hooks
312
+ artifacts: test.artifacts || [], // Keep original artifacts for now
313
+ tags: test.tags || [],
314
+ meta: test.meta || {},
315
+ opts: test.opts || {},
316
+ notes: test.notes || [],
317
+ retryAttempts: currentlyFailed || hasFailedBefore ? retryAttempts : 0, // Only show retries for failed tests
318
+ uid: test.uid,
319
+ isBdd: isBddTest,
320
+ feature: featureInfo,
321
+ // Store parent/suite titles as simple strings for worker mode serialization
322
+ parentTitle: parentTitle,
323
+ suiteTitle: suiteTitle,
324
+ }
325
+
326
+ if (existingTestIndex >= 0) {
327
+ // Update existing test with final result (including failed state)
328
+ if (existingTestIndex >= 0) reportData.tests[existingTestIndex] = testData
329
+ output.debug(`HTML Reporter: Updated existing test - ${test.title}, Final state: ${test.state}`)
330
+ } else {
331
+ // Add new test
332
+ reportData.tests.push(testData)
333
+ output.debug(`HTML Reporter: Added new test - ${test.title}, State: ${test.state}`)
334
+ }
335
+
336
+ // Track retry information - only add if there were actual retries AND the test failed at some point
337
+ const existingRetryIndex = reportData.retries.findIndex(r => r.testId === testId)
338
+
339
+ // Only track retries if:
340
+ // 1. There are retry attempts detected AND (test failed now OR failed before)
341
+ // 2. OR there's an existing retry record (meaning it failed before)
342
+ if ((retryAttempts > 0 && (currentlyFailed || hasFailedBefore)) || existingRetryIndex >= 0) {
343
+ // If no retry attempts detected but we have an existing retry record, increment it
344
+ if (retryAttempts === 0 && existingRetryIndex >= 0) {
345
+ retryAttempts = reportData.retries[existingRetryIndex].attempts + 1
346
+ testRetryAttempts.set(testId, retryAttempts)
347
+ output.debug(`HTML Reporter: Incremented retry count for duplicate test ${test.title}, attempts: ${retryAttempts}`)
348
+ }
349
+
350
+ // Remove existing retry info for this test and add updated one
351
+ reportData.retries = reportData.retries.filter(r => r.testId !== testId)
352
+ reportData.retries.push({
353
+ testId: testId,
354
+ testTitle: test.title,
355
+ attempts: retryAttempts,
356
+ finalState: test.state,
357
+ duration: test.duration || 0,
358
+ })
359
+ output.debug(`HTML Reporter: Added retry info for ${test.title}, attempts: ${retryAttempts}, state: ${test.state}`)
360
+ }
361
+
362
+ // Fallback: If this test already exists and either failed before or is failing now, it's a retry
363
+ else if (existingTestIndex >= 0 && (hasFailedBefore || currentlyFailed)) {
364
+ const fallbackAttempts = 1
365
+ testRetryAttempts.set(testId, fallbackAttempts)
366
+ reportData.retries.push({
367
+ testId: testId,
368
+ testTitle: test.title,
369
+ attempts: fallbackAttempts,
370
+ finalState: test.state,
371
+ duration: test.duration || 0,
372
+ })
373
+ output.debug(`HTML Reporter: Fallback retry detection for failed test ${test.title}, attempts: ${fallbackAttempts}`)
374
+ }
375
+ })
376
+
377
+ // Generate final report
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()
381
+
382
+ // Process artifacts now that all async tasks (including screenshots) are complete
383
+ output.debug(`HTML Reporter: Processing artifacts for ${reportData.tests.length} tests after all async tasks complete`)
384
+
385
+ reportData.tests.forEach(test => {
386
+ const originalArtifacts = test.artifacts
387
+ let collectedArtifacts = []
388
+
389
+ output.debug(`HTML Reporter: Processing test "${test.title}" (ID: ${test.id})`)
390
+ output.debug(`HTML Reporter: Test ${test.title} final artifacts: ${JSON.stringify(originalArtifacts)}`)
391
+
392
+ if (originalArtifacts) {
393
+ if (Array.isArray(originalArtifacts)) {
394
+ collectedArtifacts = originalArtifacts
395
+ output.debug(`HTML Reporter: Using array artifacts: ${collectedArtifacts.length} items`)
396
+ } else if (typeof originalArtifacts === 'object') {
397
+ // Convert object properties to array (screenshotOnFail plugin format)
398
+ collectedArtifacts = Object.values(originalArtifacts).filter(artifact => artifact)
399
+ output.debug(`HTML Reporter: Converted artifacts object to array: ${collectedArtifacts.length} items`)
400
+ output.debug(`HTML Reporter: Converted artifacts: ${JSON.stringify(collectedArtifacts)}`)
401
+ }
402
+ }
403
+
404
+ // Only use filesystem fallback if no artifacts found from screenshotOnFail plugin
405
+ if (collectedArtifacts.length === 0 && test.state === 'failed') {
406
+ output.debug(`HTML Reporter: No artifacts from plugin, trying filesystem for test "${test.title}"`)
407
+ collectedArtifacts = collectScreenshotsFromFilesystem(test, test.id)
408
+ output.debug(`HTML Reporter: Collected ${collectedArtifacts.length} screenshots from filesystem for failed test "${test.title}"`)
409
+ if (collectedArtifacts.length > 0) {
410
+ output.debug(`HTML Reporter: Filesystem screenshots for "${test.title}": ${JSON.stringify(collectedArtifacts)}`)
411
+ }
412
+ }
413
+
414
+ // Update test with processed artifacts
415
+ test.artifacts = collectedArtifacts
416
+ output.debug(`HTML Reporter: Final artifacts for "${test.title}": ${JSON.stringify(test.artifacts)}`)
417
+ })
418
+
419
+ // Calculate stats from our collected test data instead of using result.stats
420
+ const passedTests = reportData.tests.filter(t => t.state === 'passed').length
421
+ const failedTests = reportData.tests.filter(t => t.state === 'failed').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)
430
+
431
+ // Populate failures from our collected test data with enhanced details
432
+ reportData.failures = reportData.tests
433
+ .filter(t => t.state === 'failed')
434
+ .map(t => {
435
+ const testName = t.title || 'Unknown Test'
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
+ }
441
+
442
+ if (t.err) {
443
+ const errorMessage = t.err.message || t.err.toString() || 'Test failed'
444
+ const errorStack = t.err.stack || ''
445
+ const filePath = t.file || t.parent?.file || ''
446
+
447
+ // Create enhanced failure object with test details
448
+ return {
449
+ testName: testName,
450
+ featureName: featureName,
451
+ message: errorMessage,
452
+ stack: errorStack,
453
+ filePath: filePath,
454
+ toString: () => `${testName} (${featureName})\n${errorMessage}\n${errorStack}`.trim(),
455
+ }
456
+ }
457
+
458
+ return {
459
+ testName: testName,
460
+ featureName: featureName,
461
+ message: `Test failed: ${testName}`,
462
+ stack: '',
463
+ filePath: t.file || t.parent?.file || '',
464
+ toString: () => `${testName} (${featureName})\nTest failed: ${testName}`,
465
+ }
466
+ })
467
+
468
+ reportData.stats = {
469
+ tests: reportData.tests.length,
470
+ passes: passedTests,
471
+ failures: failedTests,
472
+ pending: pendingTests,
473
+ duration: reportData.duration,
474
+ failedHooks: result.stats?.failedHooks || 0,
475
+ flaky: flakyTests,
476
+ artifacts: totalArtifacts,
477
+ }
478
+
479
+ // Debug logging for final stats
480
+ output.debug(`HTML Reporter: Calculated stats - Tests: ${reportData.stats.tests}, Passes: ${reportData.stats.passes}, Failures: ${reportData.stats.failures}`)
481
+ output.debug(`HTML Reporter: Collected ${reportData.tests.length} tests in reportData`)
482
+ output.debug(`HTML Reporter: Failures array has ${reportData.failures.length} items`)
483
+ output.debug(`HTML Reporter: Retries array has ${reportData.retries.length} items`)
484
+ output.debug(`HTML Reporter: testRetryAttempts Map size: ${testRetryAttempts.size}`)
485
+
486
+ // Log retry attempts map contents
487
+ for (const [testId, attempts] of testRetryAttempts.entries()) {
488
+ output.debug(`HTML Reporter: testRetryAttempts - ${testId}: ${attempts} attempts`)
489
+ }
490
+
491
+ reportData.tests.forEach(test => {
492
+ output.debug(`HTML Reporter: Test in reportData - ${test.title}, State: ${test.state}, Retries: ${test.retryAttempts}`)
493
+ })
494
+
495
+ // Check if running with workers
496
+ if (process.env.RUNS_WITH_WORKERS) {
497
+ // In worker mode, save results to a JSON file for later consolidation
498
+ const workerId = threadId
499
+ const jsonFileName = `worker-${workerId}-results.json`
500
+ const jsonPath = path.join(reportDir, jsonFileName)
501
+
502
+ try {
503
+ // Always overwrite the file with the latest complete data from this worker
504
+ // This prevents double-counting when the event is triggered multiple times
505
+ fs.writeFileSync(jsonPath, safeJsonStringify(reportData))
506
+ output.debug(`HTML Reporter: Generated worker JSON results: ${jsonFileName}`)
507
+ } catch (error) {
508
+ output.debug(`HTML Reporter: Failed to write worker JSON: ${error.message}`)
509
+ }
510
+ return
511
+ }
512
+
513
+ // Single process mode - generate report normally
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
+ }
520
+
521
+ // Export stats if configured
522
+ if (options.exportStats) {
523
+ exportTestStats(reportData, options)
524
+ }
525
+
526
+ // Save history if configured
527
+ if (options.keepHistory) {
528
+ saveTestHistory(reportData, options)
529
+ }
530
+ })
531
+
532
+ // Handle worker consolidation after all workers complete
533
+ event.dispatcher.on(event.workers.result, async result => {
534
+ if (process.env.RUNS_WITH_WORKERS) {
535
+ // Only run consolidation in main process
536
+ await consolidateWorkerJsonResults(options)
537
+ }
538
+ })
539
+
540
+ /**
541
+ * Safely serialize data to JSON, handling circular references
542
+ */
543
+ function safeJsonStringify(data) {
544
+ const seen = new WeakSet()
545
+ return JSON.stringify(
546
+ data,
547
+ (key, value) => {
548
+ if (typeof value === 'object' && value !== null) {
549
+ if (seen.has(value)) {
550
+ // For error objects, try to extract useful information instead of "[Circular Reference]"
551
+ if (key === 'err' || key === 'error') {
552
+ return {
553
+ message: value.message || 'Error occurred',
554
+ stack: value.stack || '',
555
+ name: value.name || 'Error',
556
+ }
557
+ }
558
+ // Skip circular references for other objects
559
+ return undefined
560
+ }
561
+ seen.add(value)
562
+
563
+ // Special handling for error objects to preserve important properties
564
+ if (value instanceof Error || (value.message && value.stack)) {
565
+ return {
566
+ message: value.message || '',
567
+ stack: value.stack || '',
568
+ name: value.name || 'Error',
569
+ toString: () => value.message || 'Error occurred',
570
+ }
571
+ }
572
+ }
573
+ return value
574
+ },
575
+ 2,
576
+ )
577
+ }
578
+
579
+ function generateTestId(test) {
580
+ return crypto
581
+ .createHash('sha256')
582
+ .update(`${test.parent?.title || 'unknown'}_${test.title}`)
583
+ .digest('hex')
584
+ .substring(0, 8)
585
+ }
586
+
587
+ function collectScreenshotsFromFilesystem(test, testId) {
588
+ const screenshots = []
589
+
590
+ try {
591
+ // Common screenshot locations to check
592
+ const possibleDirs = [
593
+ reportDir, // Same as report directory
594
+ global.output_dir || './output', // Global output directory
595
+ path.resolve(global.codecept_dir || '.', 'output'), // Codecept output directory
596
+ path.resolve('.', 'output'), // Current directory output
597
+ path.resolve('.', '_output'), // Alternative output directory
598
+ path.resolve('output'), // Relative output directory
599
+ path.resolve('qa', 'output'), // QA project output directory
600
+ path.resolve('..', 'qa', 'output'), // Parent QA project output directory
601
+ ]
602
+
603
+ // Use the exact same logic as screenshotOnFail plugin's testToFileName function
604
+ const originalTestName = test.title || 'test'
605
+ const originalFeatureName = test.parent?.title || 'feature'
606
+
607
+ // Replicate testToFileName logic from lib/mocha/test.js
608
+ function replicateTestToFileName(testTitle) {
609
+ let fileName = testTitle
610
+
611
+ // Slice to 100 characters first
612
+ fileName = fileName.slice(0, 100)
613
+
614
+ // Handle data-driven tests: remove everything from '{' onwards (with 3 chars before)
615
+ if (fileName.indexOf('{') !== -1) {
616
+ fileName = fileName.substr(0, fileName.indexOf('{') - 3).trim()
617
+ }
618
+
619
+ // Apply clearString logic from utils.js
620
+ if (fileName.endsWith('.')) {
621
+ fileName = fileName.slice(0, -1)
622
+ }
623
+ fileName = fileName
624
+ .replace(/ /g, '_')
625
+ .replace(/"/g, "'")
626
+ .replace(/\//g, '_')
627
+ .replace(/</g, '(')
628
+ .replace(/>/g, ')')
629
+ .replace(/:/g, '_')
630
+ .replace(/\\/g, '_')
631
+ .replace(/\|/g, '_')
632
+ .replace(/\?/g, '.')
633
+ .replace(/\*/g, '^')
634
+ .replace(/'/g, '')
635
+
636
+ // Final slice to 100 characters
637
+ return fileName.slice(0, 100)
638
+ }
639
+
640
+ const testName = replicateTestToFileName(originalTestName)
641
+ const featureName = replicateTestToFileName(originalFeatureName)
642
+
643
+ output.debug(`HTML Reporter: Original test title: "${originalTestName}"`)
644
+ output.debug(`HTML Reporter: CodeceptJS filename: "${testName}"`)
645
+
646
+ // Generate possible screenshot names based on CodeceptJS patterns
647
+ const possibleNames = [
648
+ `${testName}.failed.png`, // Primary CodeceptJS screenshotOnFail pattern
649
+ `${testName}.failed.jpg`,
650
+ `${featureName}_${testName}.failed.png`,
651
+ `${featureName}_${testName}.failed.jpg`,
652
+ `Test_${testName}.failed.png`, // Alternative pattern
653
+ `Test_${testName}.failed.jpg`,
654
+ `${testName}.png`,
655
+ `${testName}.jpg`,
656
+ `${featureName}_${testName}.png`,
657
+ `${featureName}_${testName}.jpg`,
658
+ `failed_${testName}.png`,
659
+ `failed_${testName}.jpg`,
660
+ `screenshot_${testId}.png`,
661
+ `screenshot_${testId}.jpg`,
662
+ 'screenshot.png',
663
+ 'screenshot.jpg',
664
+ 'failure.png',
665
+ 'failure.jpg',
666
+ ]
667
+
668
+ output.debug(`HTML Reporter: Checking ${possibleNames.length} possible screenshot names for "${testName}"`)
669
+
670
+ // Search for screenshots in possible directories
671
+ for (const dir of possibleDirs) {
672
+ output.debug(`HTML Reporter: Checking directory: ${dir}`)
673
+ if (!fs.existsSync(dir)) {
674
+ output.debug(`HTML Reporter: Directory does not exist: ${dir}`)
675
+ continue
676
+ }
677
+
678
+ try {
679
+ const files = fs.readdirSync(dir)
680
+ output.debug(`HTML Reporter: Found ${files.length} files in ${dir}`)
681
+
682
+ // Look for exact matches first
683
+ for (const name of possibleNames) {
684
+ if (files.includes(name)) {
685
+ const fullPath = path.join(dir, name)
686
+ if (!screenshots.includes(fullPath)) {
687
+ screenshots.push(fullPath)
688
+ output.debug(`HTML Reporter: Found screenshot: ${fullPath}`)
689
+ }
690
+ }
691
+ }
692
+
693
+ // Look for screenshot files that are specifically for this test
694
+ // Be more strict to avoid cross-test contamination
695
+ const screenshotFiles = files.filter(file => {
696
+ const lowerFile = file.toLowerCase()
697
+ const lowerTestName = testName.toLowerCase()
698
+ const lowerFeatureName = featureName.toLowerCase()
699
+
700
+ return (
701
+ file.match(/\.(png|jpg|jpeg|gif|webp|bmp)$/i) &&
702
+ // Exact test name matches with .failed pattern (most specific)
703
+ (file === `${testName}.failed.png` ||
704
+ file === `${testName}.failed.jpg` ||
705
+ file === `${featureName}_${testName}.failed.png` ||
706
+ file === `${featureName}_${testName}.failed.jpg` ||
707
+ file === `Test_${testName}.failed.png` ||
708
+ file === `Test_${testName}.failed.jpg` ||
709
+ // Word boundary checks for .failed pattern
710
+ (lowerFile.includes('.failed.') &&
711
+ (lowerFile.startsWith(lowerTestName + '.') || lowerFile.startsWith(lowerFeatureName + '_' + lowerTestName + '.') || lowerFile.startsWith('test_' + lowerTestName + '.'))))
712
+ )
713
+ })
714
+
715
+ for (const file of screenshotFiles) {
716
+ const fullPath = path.join(dir, file)
717
+ if (!screenshots.includes(fullPath)) {
718
+ screenshots.push(fullPath)
719
+ output.debug(`HTML Reporter: Found related screenshot: ${fullPath}`)
720
+ }
721
+ }
722
+ } catch (error) {
723
+ // Ignore directory read errors
724
+ output.debug(`HTML Reporter: Could not read directory ${dir}: ${error.message}`)
725
+ }
726
+ }
727
+ } catch (error) {
728
+ output.debug(`HTML Reporter: Error collecting screenshots: ${error.message}`)
729
+ }
730
+
731
+ return screenshots
732
+ }
733
+
734
+ function isBddGherkinTest(test, suite) {
735
+ // Check if the suite has BDD/Gherkin properties
736
+ return !!(suite && (suite.feature || suite.file?.endsWith('.feature')))
737
+ }
738
+
739
+ function getBddFeatureInfo(test, suite) {
740
+ if (!suite) return null
741
+
742
+ return {
743
+ name: suite.feature?.name || suite.title,
744
+ description: suite.feature?.description || suite.comment || '',
745
+ language: suite.feature?.language || 'en',
746
+ tags: suite.tags || [],
747
+ file: suite.file || '',
748
+ }
749
+ }
750
+
751
+ function exportTestStats(data, config) {
752
+ const statsPath = path.resolve(reportDir, config.exportStatsPath)
753
+
754
+ const exportData = {
755
+ timestamp: data.endTime, // Already an ISO string
756
+ duration: data.duration,
757
+ stats: data.stats,
758
+ retries: data.retries,
759
+ testCount: data.tests.length,
760
+ passedTests: data.tests.filter(t => t.state === 'passed').length,
761
+ failedTests: data.tests.filter(t => t.state === 'failed').length,
762
+ pendingTests: data.tests.filter(t => t.state === 'pending').length,
763
+ tests: data.tests.map(test => ({
764
+ id: test.id,
765
+ title: test.title,
766
+ feature: test.parent?.title || 'Unknown',
767
+ state: test.state,
768
+ duration: test.duration,
769
+ tags: test.tags,
770
+ meta: test.meta,
771
+ retryAttempts: test.retryAttempts,
772
+ uid: test.uid,
773
+ })),
774
+ }
775
+
776
+ try {
777
+ fs.writeFileSync(statsPath, JSON.stringify(exportData, null, 2))
778
+ output.print(`Test stats exported to: ${statsPath}`)
779
+ } catch (error) {
780
+ output.print(`Failed to export test stats: ${error.message}`)
781
+ }
782
+ }
783
+
784
+ function saveTestHistory(data, config) {
785
+ const historyPath = path.resolve(reportDir, config.historyPath)
786
+ let history = []
787
+
788
+ // Load existing history
789
+ try {
790
+ if (fs.existsSync(historyPath)) {
791
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf8'))
792
+ }
793
+ } catch (error) {
794
+ output.print(`Failed to load existing history: ${error.message}`)
795
+ }
796
+
797
+ // Add current run to history
798
+ history.unshift({
799
+ timestamp: data.endTime, // Already an ISO string
800
+ duration: data.duration,
801
+ stats: data.stats,
802
+ retries: data.retries.length,
803
+ testCount: data.tests.length,
804
+ })
805
+
806
+ // Limit history entries
807
+ if (history.length > config.maxHistoryEntries) {
808
+ history = history.slice(0, config.maxHistoryEntries)
809
+ }
810
+
811
+ try {
812
+ fs.writeFileSync(historyPath, JSON.stringify(history, null, 2))
813
+ output.print(`Test history saved to: ${historyPath}`)
814
+ } catch (error) {
815
+ output.print(`Failed to save test history: ${error.message}`)
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Consolidates JSON reports from multiple workers into a single HTML report
821
+ */
822
+ async function consolidateWorkerJsonResults(config) {
823
+ const jsonFiles = fs.readdirSync(reportDir).filter(file => file.startsWith('worker-') && file.endsWith('-results.json'))
824
+
825
+ if (jsonFiles.length === 0) {
826
+ output.debug('HTML Reporter: No worker JSON results found to consolidate')
827
+ return
828
+ }
829
+
830
+ output.debug(`HTML Reporter: Found ${jsonFiles.length} worker JSON files to consolidate`)
831
+
832
+ // Initialize consolidated data structure
833
+ const consolidatedData = {
834
+ stats: {
835
+ tests: 0,
836
+ passes: 0,
837
+ failures: 0,
838
+ pending: 0,
839
+ skipped: 0,
840
+ duration: 0,
841
+ failedHooks: 0,
842
+ },
843
+ tests: [],
844
+ failures: [],
845
+ hooks: [],
846
+ startTime: new Date(),
847
+ endTime: new Date(),
848
+ retries: [],
849
+ duration: 0,
850
+ }
851
+
852
+ try {
853
+ // Process each worker's JSON file
854
+ for (const jsonFile of jsonFiles) {
855
+ const jsonPath = path.join(reportDir, jsonFile)
856
+ try {
857
+ const workerData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
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
+
863
+ // Merge stats
864
+ if (workerData.stats) {
865
+ consolidatedData.stats.passes += workerData.stats.passes || 0
866
+ consolidatedData.stats.failures += workerData.stats.failures || 0
867
+ consolidatedData.stats.tests += workerData.stats.tests || 0
868
+ consolidatedData.stats.pending += workerData.stats.pending || 0
869
+ consolidatedData.stats.skipped += workerData.stats.skipped || 0
870
+ consolidatedData.stats.duration += workerData.stats.duration || 0
871
+ consolidatedData.stats.failedHooks += workerData.stats.failedHooks || 0
872
+ }
873
+
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
+ }
882
+ if (workerData.failures) consolidatedData.failures.push(...workerData.failures)
883
+ if (workerData.hooks) consolidatedData.hooks.push(...workerData.hooks)
884
+ if (workerData.retries) consolidatedData.retries.push(...workerData.retries)
885
+
886
+ // Update timestamps
887
+ if (workerData.startTime) {
888
+ const workerStart = new Date(workerData.startTime).getTime()
889
+ const currentStart = new Date(consolidatedData.startTime).getTime()
890
+ if (workerStart < currentStart) {
891
+ consolidatedData.startTime = workerData.startTime
892
+ }
893
+ }
894
+
895
+ if (workerData.endTime) {
896
+ const workerEnd = new Date(workerData.endTime).getTime()
897
+ const currentEnd = new Date(consolidatedData.endTime).getTime()
898
+ if (workerEnd > currentEnd) {
899
+ consolidatedData.endTime = workerData.endTime
900
+ }
901
+ }
902
+
903
+ // Update duration
904
+ if (workerData.duration) {
905
+ consolidatedData.duration = Math.max(consolidatedData.duration, workerData.duration)
906
+ }
907
+
908
+ // Clean up the worker JSON file
909
+ try {
910
+ fs.unlinkSync(jsonPath)
911
+ } catch (error) {
912
+ output.print(`Failed to delete worker JSON file ${jsonFile}: ${error.message}`)
913
+ }
914
+ } catch (error) {
915
+ output.print(`Failed to process worker JSON file ${jsonFile}: ${error.message}`)
916
+ }
917
+ }
918
+
919
+ // Generate the final HTML report
920
+ generateHtmlReport(consolidatedData, config)
921
+
922
+ // Export stats if configured
923
+ if (config.exportStats) {
924
+ exportTestStats(consolidatedData, config)
925
+ }
926
+
927
+ // Save history if configured
928
+ if (config.keepHistory) {
929
+ saveTestHistory(consolidatedData, config)
930
+ }
931
+
932
+ output.debug(`HTML Reporter: Successfully consolidated ${jsonFiles.length} worker reports`)
933
+ } catch (error) {
934
+ output.debug(`HTML Reporter: Failed to consolidate worker reports: ${error.message}`)
935
+ }
936
+ }
937
+
938
+ async function generateHtmlReport(data, config) {
939
+ const reportPath = path.join(reportDir, config.reportFileName)
940
+
941
+ // Load history if available
942
+ let history = []
943
+ if (config.keepHistory) {
944
+ const historyPath = path.resolve(reportDir, config.historyPath)
945
+ try {
946
+ if (fs.existsSync(historyPath)) {
947
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf8')) // Show all available history
948
+ }
949
+ } catch (error) {
950
+ output.print(`Failed to load history for report: ${error.message}`)
951
+ }
952
+
953
+ // Add current run to history for chart display (before saving to file)
954
+ const currentRun = {
955
+ timestamp: data.endTime, // Already an ISO string
956
+ duration: data.duration,
957
+ stats: data.stats,
958
+ retries: data.retries.length,
959
+ testCount: data.tests.length,
960
+ }
961
+ history.unshift(currentRun)
962
+
963
+ // Limit history entries for chart display
964
+ if (history.length > config.maxHistoryEntries) {
965
+ history = history.slice(0, config.maxHistoryEntries)
966
+ }
967
+ }
968
+
969
+ // Get system information
970
+ const systemInfo = await getMachineInfo()
971
+
972
+ const html = template(getHtmlTemplate(), {
973
+ title: `CodeceptJS Test Report v${Codecept.version()}`,
974
+ timestamp: data.endTime, // Already an ISO string
975
+ duration: formatDuration(data.duration),
976
+ stats: JSON.stringify(data.stats),
977
+ history: JSON.stringify(history),
978
+ statsHtml: generateStatsHtml(data.stats),
979
+ testsHtml: generateTestsHtml(data.tests, config),
980
+ retriesHtml: config.showRetries ? generateRetriesHtml(data.retries) : '',
981
+ cssStyles: getCssStyles(),
982
+ jsScripts: getJsScripts(),
983
+ showRetries: config.showRetries ? 'block' : 'none',
984
+ showHistory: config.keepHistory && history.length > 0 ? 'block' : 'none',
985
+ codeceptVersion: Codecept.version(),
986
+ systemInfoHtml: generateSystemInfoHtml(systemInfo),
987
+ })
988
+
989
+ fs.writeFileSync(reportPath, html)
990
+ output.print(`HTML Report saved to: ${reportPath}`)
991
+ }
992
+
993
+ function generateStatsHtml(stats) {
994
+ const passed = stats.passes || 0
995
+ const failed = stats.failures || 0
996
+ const pending = stats.pending || 0
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'
1002
+
1003
+ return `
1004
+ <div class="stats-cards">
1005
+ <div class="stat-card total">
1006
+ <h3>Total</h3>
1007
+ <span class="stat-number">${total}</span>
1008
+ </div>
1009
+ <div class="stat-card passed">
1010
+ <h3>Passed</h3>
1011
+ <span class="stat-number">${passed}</span>
1012
+ </div>
1013
+ <div class="stat-card failed">
1014
+ <h3>Failed</h3>
1015
+ <span class="stat-number">${failed}</span>
1016
+ </div>
1017
+ <div class="stat-card pending">
1018
+ <h3>Skipped</h3>
1019
+ <span class="stat-number">${pending}</span>
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>
1033
+ </div>
1034
+ <div class="pie-chart-container">
1035
+ <canvas id="statsChart" width="300" height="300"></canvas>
1036
+ <script>
1037
+ // Pie chart data will be rendered by JavaScript
1038
+ window.chartData = {
1039
+ passed: ${passed},
1040
+ failed: ${failed},
1041
+ pending: ${pending}
1042
+ };
1043
+ </script>
1044
+ </div>
1045
+ `
1046
+ }
1047
+
1048
+ function generateTestsHtml(tests, config) {
1049
+ if (!tests || tests.length === 0) {
1050
+ return '<p>No tests found.</p>'
1051
+ }
1052
+
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
+ })
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()
1065
+ return `
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('')}
1117
+ </div>
1118
+ </section>
1119
+ `
1120
+ })
1121
+ .join('')
1122
+ }
1123
+
1124
+ function generateStepsHtml(steps) {
1125
+ if (!steps || steps.length === 0) return ''
1126
+
1127
+ const stepsHtml = steps
1128
+ .map(step => {
1129
+ const statusClass = step.status || 'unknown'
1130
+ const args = step.args ? step.args.map(arg => JSON.stringify(arg)).join(', ') : ''
1131
+ const stepName = step.name || 'unknown step'
1132
+ const actor = step.actor || 'I'
1133
+
1134
+ return `
1135
+ <div class="step-item ${statusClass}">
1136
+ <span class="step-status ${statusClass}">●</span>
1137
+ <span class="step-title">${actor}.${stepName}(${args})</span>
1138
+ <span class="step-duration">${formatDuration(step.duration)}</span>
1139
+ </div>
1140
+ `
1141
+ })
1142
+ .join('')
1143
+
1144
+ return `
1145
+ <div class="steps-section">
1146
+ <h4>Steps:</h4>
1147
+ <div class="steps-list">${stepsHtml}</div>
1148
+ </div>
1149
+ `
1150
+ }
1151
+
1152
+ function generateBddStepsHtml(steps) {
1153
+ if (!steps || steps.length === 0) return ''
1154
+
1155
+ const stepsHtml = steps
1156
+ .map(step => {
1157
+ const statusClass = step.status || 'unknown'
1158
+ const keyword = step.keyword || 'Given'
1159
+ const text = step.text || ''
1160
+ const comment = step.comment ? `<div class="step-comment">${escapeHtml(step.comment)}</div>` : ''
1161
+
1162
+ return `
1163
+ <div class="bdd-step-item ${statusClass}">
1164
+ <span class="step-status ${statusClass}">●</span>
1165
+ <span class="bdd-keyword">${keyword}</span>
1166
+ <span class="bdd-step-text">${escapeHtml(text)}</span>
1167
+ <span class="step-duration">${formatDuration(step.duration)}</span>
1168
+ ${comment}
1169
+ </div>
1170
+ `
1171
+ })
1172
+ .join('')
1173
+
1174
+ return `
1175
+ <div class="bdd-steps-section">
1176
+ <h4>Scenario Steps:</h4>
1177
+ <div class="bdd-steps-list">${stepsHtml}</div>
1178
+ </div>
1179
+ `
1180
+ }
1181
+
1182
+ function generateBddFeatureHtml(feature) {
1183
+ if (!feature) return ''
1184
+
1185
+ const description = feature.description ? `<div class="feature-description">${escapeHtml(feature.description)}</div>` : ''
1186
+ const featureTags = feature.tags && feature.tags.length > 0 ? `<div class="feature-tags">${feature.tags.map(tag => `<span class="feature-tag">${escapeHtml(tag)}</span>`).join('')}</div>` : ''
1187
+
1188
+ return `
1189
+ <div class="bdd-feature-section">
1190
+ <h4>Feature Information:</h4>
1191
+ <div class="feature-info">
1192
+ <div class="feature-name">Feature: ${escapeHtml(feature.name)}</div>
1193
+ ${description}
1194
+ ${featureTags}
1195
+ ${feature.file ? `<div class="feature-file">File: ${escapeHtml(feature.file)}</div>` : ''}
1196
+ </div>
1197
+ </div>
1198
+ `
1199
+ }
1200
+
1201
+ function generateHooksHtml(hooks) {
1202
+ if (!hooks || hooks.length === 0) return ''
1203
+
1204
+ const hooksHtml = hooks
1205
+ .map(hook => {
1206
+ const statusClass = hook.status || 'unknown'
1207
+ const hookType = hook.type || 'hook'
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>` : ''
1211
+
1212
+ return `
1213
+ <div class="hook-item ${statusClass}">
1214
+ <span class="hook-status ${statusClass}">●</span>
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>
1222
+ </div>
1223
+ `
1224
+ })
1225
+ .join('')
1226
+
1227
+ return `
1228
+ <div class="hooks-section">
1229
+ <h4>Hooks:</h4>
1230
+ <div class="hooks-list">${hooksHtml}</div>
1231
+ </div>
1232
+ `
1233
+ }
1234
+
1235
+ function generateMetadataHtml(meta, opts) {
1236
+ const allMeta = { ...(opts || {}), ...(meta || {}) }
1237
+ if (!allMeta || Object.keys(allMeta).length === 0) return ''
1238
+
1239
+ const metaHtml = Object.entries(allMeta)
1240
+ .filter(([key, value]) => value !== undefined && value !== null)
1241
+ .map(([key, value]) => {
1242
+ const displayValue = typeof value === 'object' ? JSON.stringify(value) : value.toString()
1243
+ return `<div class="meta-item"><span class="meta-key">${escapeHtml(key)}:</span> <span class="meta-value">${escapeHtml(displayValue)}</span></div>`
1244
+ })
1245
+ .join('')
1246
+
1247
+ return `
1248
+ <div class="metadata-section">
1249
+ <h4>Metadata:</h4>
1250
+ <div class="metadata-list">${metaHtml}</div>
1251
+ </div>
1252
+ `
1253
+ }
1254
+
1255
+ function generateTagsHtml(tags) {
1256
+ if (!tags || tags.length === 0) return ''
1257
+
1258
+ const tagsHtml = tags.map(tag => `<span class="test-tag">${escapeHtml(tag)}</span>`).join('')
1259
+
1260
+ return `
1261
+ <div class="tags-section">
1262
+ <h4>Tags:</h4>
1263
+ <div class="tags-list">${tagsHtml}</div>
1264
+ </div>
1265
+ `
1266
+ }
1267
+
1268
+ function generateNotesHtml(notes) {
1269
+ if (!notes || notes.length === 0) return ''
1270
+
1271
+ const notesHtml = notes.map(note => `<div class="note-item note-${note.type || 'info'}"><span class="note-type">${note.type || 'info'}:</span> <span class="note-text">${escapeHtml(note.text)}</span></div>`).join('')
1272
+
1273
+ return `
1274
+ <div class="notes-section">
1275
+ <h4>Notes:</h4>
1276
+ <div class="notes-list">${notesHtml}</div>
1277
+ </div>
1278
+ `
1279
+ }
1280
+
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
+
1285
+ return `
1286
+ <div class="retry-section">
1287
+ <h4>Retry History:</h4>
1288
+ <div class="retry-info">
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>
1296
+ </div>
1297
+ </div>
1298
+ `
1299
+ }
1300
+
1301
+ function generateArtifactsHtml(artifacts, isFailedTest = false) {
1302
+ if (!artifacts || artifacts.length === 0) {
1303
+ output.debug(`HTML Reporter: No artifacts found for test`)
1304
+ return ''
1305
+ }
1306
+
1307
+ output.debug(`HTML Reporter: Processing ${artifacts.length} artifacts, isFailedTest: ${isFailedTest}`)
1308
+ output.debug(`HTML Reporter: Artifacts: ${JSON.stringify(artifacts)}`)
1309
+
1310
+ // Separate screenshots from other artifacts
1311
+ const screenshots = []
1312
+ const otherArtifacts = []
1313
+
1314
+ artifacts.forEach(artifact => {
1315
+ output.debug(`HTML Reporter: Processing artifact: ${artifact} (type: ${typeof artifact})`)
1316
+
1317
+ // Handle different artifact formats
1318
+ let artifactPath = artifact
1319
+ if (typeof artifact === 'object' && artifact.path) {
1320
+ artifactPath = artifact.path
1321
+ } else if (typeof artifact === 'object' && artifact.file) {
1322
+ artifactPath = artifact.file
1323
+ } else if (typeof artifact === 'object' && artifact.src) {
1324
+ artifactPath = artifact.src
1325
+ }
1326
+
1327
+ // Check if it's a screenshot file
1328
+ if (typeof artifactPath === 'string' && artifactPath.match(/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i)) {
1329
+ screenshots.push(artifactPath)
1330
+ output.debug(`HTML Reporter: Found screenshot: ${artifactPath}`)
1331
+ } else {
1332
+ otherArtifacts.push(artifact)
1333
+ output.debug(`HTML Reporter: Found other artifact: ${artifact}`)
1334
+ }
1335
+ })
1336
+
1337
+ output.debug(`HTML Reporter: Found ${screenshots.length} screenshots and ${otherArtifacts.length} other artifacts`)
1338
+
1339
+ let artifactsHtml = ''
1340
+
1341
+ // For failed tests, prominently display screenshots
1342
+ if (isFailedTest && screenshots.length > 0) {
1343
+ const screenshotsHtml = screenshots
1344
+ .map(screenshot => {
1345
+ let relativePath = path.relative(reportDir, screenshot)
1346
+ const filename = path.basename(screenshot)
1347
+
1348
+ // If relative path goes up directories, try to find the file in common locations
1349
+ if (relativePath.startsWith('..')) {
1350
+ // Try to find screenshot relative to output directory
1351
+ const outputRelativePath = path.relative(reportDir, path.resolve(screenshot))
1352
+ if (!outputRelativePath.startsWith('..')) {
1353
+ relativePath = outputRelativePath
1354
+ } else {
1355
+ // Use just the filename if file is in same directory as report
1356
+ const sameDir = path.join(reportDir, filename)
1357
+ if (fs.existsSync(sameDir)) {
1358
+ relativePath = filename
1359
+ } else {
1360
+ // Keep original path as fallback
1361
+ relativePath = screenshot
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ output.debug(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1367
+
1368
+ return `
1369
+ <div class="screenshot-container">
1370
+ <div class="screenshot-header">
1371
+ <span class="screenshot-label">📸 ${escapeHtml(filename)}</span>
1372
+ </div>
1373
+ <img src="${relativePath}" alt="Test failure screenshot" class="failure-screenshot" onclick="openImageModal(this.src)"/>
1374
+ </div>
1375
+ `
1376
+ })
1377
+ .join('')
1378
+
1379
+ artifactsHtml += `
1380
+ <div class="screenshots-section">
1381
+ <h4>Screenshots:</h4>
1382
+ <div class="screenshots-list">${screenshotsHtml}</div>
1383
+ </div>
1384
+ `
1385
+ } else if (screenshots.length > 0) {
1386
+ // For non-failed tests, display screenshots normally
1387
+ const screenshotsHtml = screenshots
1388
+ .map(screenshot => {
1389
+ let relativePath = path.relative(reportDir, screenshot)
1390
+ const filename = path.basename(screenshot)
1391
+
1392
+ // If relative path goes up directories, try to find the file in common locations
1393
+ if (relativePath.startsWith('..')) {
1394
+ // Try to find screenshot relative to output directory
1395
+ const outputRelativePath = path.relative(reportDir, path.resolve(screenshot))
1396
+ if (!outputRelativePath.startsWith('..')) {
1397
+ relativePath = outputRelativePath
1398
+ } else {
1399
+ // Use just the filename if file is in same directory as report
1400
+ const sameDir = path.join(reportDir, filename)
1401
+ if (fs.existsSync(sameDir)) {
1402
+ relativePath = filename
1403
+ } else {
1404
+ // Keep original path as fallback
1405
+ relativePath = screenshot
1406
+ }
1407
+ }
1408
+ }
1409
+
1410
+ output.debug(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1411
+ return `<img src="${relativePath}" alt="Screenshot" class="artifact-image" onclick="openImageModal(this.src)"/>`
1412
+ })
1413
+ .join('')
1414
+
1415
+ artifactsHtml += `
1416
+ <div class="screenshots-section">
1417
+ <h4>Screenshots:</h4>
1418
+ <div class="screenshots-list">${screenshotsHtml}</div>
1419
+ </div>
1420
+ `
1421
+ }
1422
+
1423
+ // Display other artifacts if any
1424
+ if (otherArtifacts.length > 0) {
1425
+ const otherArtifactsHtml = otherArtifacts.map(artifact => `<div class="artifact-item">${escapeHtml(artifact.toString())}</div>`).join('')
1426
+
1427
+ artifactsHtml += `
1428
+ <div class="other-artifacts-section">
1429
+ <h4>Other Artifacts:</h4>
1430
+ <div class="artifacts-list">${otherArtifactsHtml}</div>
1431
+ </div>
1432
+ `
1433
+ }
1434
+
1435
+ return artifactsHtml
1436
+ ? `
1437
+ <div class="artifacts-section">
1438
+ ${artifactsHtml}
1439
+ </div>
1440
+ `
1441
+ : ''
1442
+ }
1443
+
1444
+ function generateFailuresHtml(failures) {
1445
+ if (!failures || failures.length === 0) {
1446
+ return '<p>No failures.</p>'
1447
+ }
1448
+
1449
+ return failures
1450
+ .map((failure, index) => {
1451
+ // Helper function to safely extract string values
1452
+ const safeString = value => {
1453
+ if (!value) return ''
1454
+ if (typeof value === 'string') return value
1455
+ if (typeof value === 'object' && value.toString) {
1456
+ const str = value.toString()
1457
+ return str === '[object Object]' ? '' : str
1458
+ }
1459
+ return String(value)
1460
+ }
1461
+
1462
+ if (typeof failure === 'object' && failure !== null) {
1463
+ // Enhanced failure object with test details
1464
+ console.log('this is failure', failure)
1465
+ const testName = safeString(failure.testName) || 'Unknown Test'
1466
+ const featureName = safeString(failure.featureName) || 'Unknown Feature'
1467
+ let message = safeString(failure.message) || 'Test failed'
1468
+ const stack = safeString(failure.stack) || ''
1469
+ const filePath = safeString(failure.filePath) || ''
1470
+
1471
+ // If message is still "[object Object]", try to extract from the failure object itself
1472
+ if (message === '[object Object]' || message === '') {
1473
+ if (failure.err && failure.err.message) {
1474
+ message = safeString(failure.err.message)
1475
+ } else if (failure.error && failure.error.message) {
1476
+ message = safeString(failure.error.message)
1477
+ } else if (failure.toString && typeof failure.toString === 'function') {
1478
+ const str = failure.toString()
1479
+ message = str === '[object Object]' ? 'Test failed' : str
1480
+ } else {
1481
+ message = 'Test failed'
1482
+ }
1483
+ }
1484
+
1485
+ return `
1486
+ <div class="failure-item">
1487
+ <h4>Failure ${index + 1}: ${escapeHtml(testName)}</h4>
1488
+ <div class="failure-meta">
1489
+ <span class="failure-feature">Feature: ${escapeHtml(featureName)}</span>
1490
+ ${filePath ? `<span class="failure-file">File: <a href="file://${filePath}" target="_blank">${escapeHtml(filePath)}</a></span>` : ''}
1491
+ </div>
1492
+ <div class="failure-message">
1493
+ <strong>Error:</strong> ${escapeHtml(message)}
1494
+ </div>
1495
+ ${stack ? `<pre class="failure-stack">${escapeHtml(stack.replace(/\x1b\[[0-9;]*m/g, ''))}</pre>` : ''}
1496
+ </div>
1497
+ `
1498
+ } else {
1499
+ // Fallback for simple string failures
1500
+ const failureText = safeString(failure).replace(/\x1b\[[0-9;]*m/g, '') || 'Test failed'
1501
+ return `
1502
+ <div class="failure-item">
1503
+ <h4>Failure ${index + 1}</h4>
1504
+ <pre class="failure-details">${escapeHtml(failureText)}</pre>
1505
+ </div>
1506
+ `
1507
+ }
1508
+ })
1509
+ .join('')
1510
+ }
1511
+
1512
+ function generateRetriesHtml(retries) {
1513
+ if (!retries || retries.length === 0) {
1514
+ return '<p>No retried tests.</p>'
1515
+ }
1516
+
1517
+ return retries
1518
+ .map(
1519
+ retry => `
1520
+ <div class="retry-item">
1521
+ <h4>${retry.testTitle}</h4>
1522
+ <div class="retry-details">
1523
+ <span>Attempts: <strong>${retry.attempts}</strong></span>
1524
+ <span>Final State: <span class="status-badge ${retry.finalState}">${retry.finalState}</span></span>
1525
+ <span>Duration: ${formatDuration(retry.duration)}</span>
1526
+ </div>
1527
+ </div>
1528
+ `,
1529
+ )
1530
+ .join('')
1531
+ }
1532
+
1533
+ function formatDuration(duration) {
1534
+ if (!duration) return '0ms'
1535
+ if (duration < 1000) return `${duration}ms`
1536
+ return `${(duration / 1000).toFixed(2)}s`
1537
+ }
1538
+
1539
+ function escapeHtml(text) {
1540
+ if (!text) return ''
1541
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
1542
+ }
1543
+
1544
+ function getErrorMessage(test) {
1545
+ if (!test) return 'Test failed'
1546
+
1547
+ // Helper function to safely extract string from potentially circular objects
1548
+ const safeExtract = (obj, prop) => {
1549
+ try {
1550
+ if (!obj || typeof obj !== 'object') return ''
1551
+ const value = obj[prop]
1552
+ if (typeof value === 'string') return value
1553
+ if (value && typeof value.toString === 'function') {
1554
+ const str = value.toString()
1555
+ return str === '[object Object]' ? '' : str
1556
+ }
1557
+ return ''
1558
+ } catch (e) {
1559
+ return ''
1560
+ }
1561
+ }
1562
+
1563
+ // Helper function to safely stringify objects avoiding circular references
1564
+ const safeStringify = obj => {
1565
+ try {
1566
+ if (!obj) return ''
1567
+ if (typeof obj === 'string') return obj
1568
+
1569
+ // Try to get message property first
1570
+ if (obj.message && typeof obj.message === 'string') {
1571
+ return obj.message
1572
+ }
1573
+
1574
+ // For error objects, extract key properties manually
1575
+ if (obj instanceof Error || (obj.name && obj.message)) {
1576
+ return obj.message || obj.toString() || 'Error occurred'
1577
+ }
1578
+
1579
+ // For other objects, try toString first
1580
+ if (obj.toString && typeof obj.toString === 'function') {
1581
+ const str = obj.toString()
1582
+ if (str !== '[object Object]' && !str.includes('[Circular Reference]')) {
1583
+ return str
1584
+ }
1585
+ }
1586
+
1587
+ // Last resort: extract message-like properties
1588
+ if (obj.message) return obj.message
1589
+ if (obj.description) return obj.description
1590
+ if (obj.text) return obj.text
1591
+
1592
+ return 'Error occurred'
1593
+ } catch (e) {
1594
+ return 'Error occurred'
1595
+ }
1596
+ }
1597
+
1598
+ let errorMessage = ''
1599
+ let errorStack = ''
1600
+
1601
+ // Primary error source
1602
+ if (test.err) {
1603
+ errorMessage = safeExtract(test.err, 'message') || safeStringify(test.err)
1604
+ errorStack = safeExtract(test.err, 'stack')
1605
+ }
1606
+
1607
+ // Alternative error sources for different test frameworks
1608
+ if (!errorMessage && test.error) {
1609
+ errorMessage = safeExtract(test.error, 'message') || safeStringify(test.error)
1610
+ errorStack = safeExtract(test.error, 'stack')
1611
+ }
1612
+
1613
+ // Check for nested error in parent
1614
+ if (!errorMessage && test.parent && test.parent.err) {
1615
+ errorMessage = safeExtract(test.parent.err, 'message') || safeStringify(test.parent.err)
1616
+ errorStack = safeExtract(test.parent.err, 'stack')
1617
+ }
1618
+
1619
+ // Check for error details array (some frameworks use this)
1620
+ if (!errorMessage && test.err && test.err.details && Array.isArray(test.err.details)) {
1621
+ errorMessage = test.err.details
1622
+ .map(item => safeExtract(item, 'message') || safeStringify(item))
1623
+ .filter(msg => msg && msg !== '[Circular]')
1624
+ .join(' ')
1625
+ }
1626
+
1627
+ // Fallback to test title if no error message found
1628
+ if (!errorMessage || errorMessage === '[Circular]') {
1629
+ errorMessage = `Test failed: ${test.title || 'Unknown test'}`
1630
+ }
1631
+
1632
+ // Clean ANSI escape codes and remove circular reference markers
1633
+ const cleanMessage = (errorMessage || '')
1634
+ .replace(/\x1b\[[0-9;]*m/g, '')
1635
+ .replace(/\[Circular\]/g, '')
1636
+ .replace(/\s+/g, ' ')
1637
+ .trim()
1638
+
1639
+ const cleanStack = (errorStack || '')
1640
+ .replace(/\x1b\[[0-9;]*m/g, '')
1641
+ .replace(/\[Circular\]/g, '')
1642
+ .trim()
1643
+
1644
+ // Return combined error information
1645
+ if (cleanStack && cleanStack !== cleanMessage && !cleanMessage.includes(cleanStack)) {
1646
+ return `${cleanMessage}\n\nStack trace:\n${cleanStack}`
1647
+ }
1648
+
1649
+ return cleanMessage
1650
+ }
1651
+
1652
+ function generateSystemInfoHtml(systemInfo) {
1653
+ if (!systemInfo) return ''
1654
+
1655
+ const formatInfo = (key, value) => {
1656
+ if (Array.isArray(value) && value.length > 1) {
1657
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value[1])}</span></div>`
1658
+ } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') {
1659
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value)}</span></div>`
1660
+ }
1661
+ return ''
1662
+ }
1663
+
1664
+ const infoItems = [
1665
+ formatInfo('Node.js', systemInfo.nodeInfo),
1666
+ formatInfo('OS', systemInfo.osInfo),
1667
+ formatInfo('CPU', systemInfo.cpuInfo),
1668
+ formatInfo('Chrome', systemInfo.chromeInfo),
1669
+ formatInfo('Edge', systemInfo.edgeInfo),
1670
+ formatInfo('Firefox', systemInfo.firefoxInfo),
1671
+ formatInfo('Safari', systemInfo.safariInfo),
1672
+ formatInfo('Playwright Browsers', systemInfo.playwrightBrowsers),
1673
+ ]
1674
+ .filter(item => item)
1675
+ .join('')
1676
+
1677
+ if (!infoItems) return ''
1678
+
1679
+ return `
1680
+ <section class="system-info-section">
1681
+ <div class="system-info-header" onclick="toggleSystemInfo()">
1682
+ <h3>Environment Information</h3>
1683
+ <span class="toggle-icon">▼</span>
1684
+ </div>
1685
+ <div class="system-info-content" id="systemInfoContent">
1686
+ <div class="system-info-grid">
1687
+ ${infoItems}
1688
+ </div>
1689
+ </div>
1690
+ </section>
1691
+ `
1692
+ }
1693
+
1694
+ function getHtmlTemplate() {
1695
+ return `
1696
+ <!DOCTYPE html>
1697
+ <html lang="en">
1698
+ <head>
1699
+ <meta charset="UTF-8">
1700
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1701
+ <title>{{title}}</title>
1702
+ <style>{{cssStyles}}</style>
1703
+ </head>
1704
+ <body>
1705
+ <header class="report-header">
1706
+ <h1>{{title}}</h1>
1707
+ <div class="report-meta">
1708
+ <span>Generated: {{timestamp}}</span>
1709
+ <span>Duration: {{duration}}</span>
1710
+ </div>
1711
+ </header>
1712
+
1713
+ <main class="report-content">
1714
+ {{systemInfoHtml}}
1715
+
1716
+ <section class="stats-section">
1717
+ <h2>Test Statistics</h2>
1718
+ {{statsHtml}}
1719
+ </section>
1720
+
1721
+ <section class="test-performance-section">
1722
+ <h2>Test Performance Analysis</h2>
1723
+ <div class="performance-container">
1724
+ <div class="performance-group">
1725
+ <h3>⏱️ Longest Running Tests</h3>
1726
+ <div id="longestTests" class="performance-list"></div>
1727
+ </div>
1728
+ <div class="performance-group">
1729
+ <h3>⚡ Fastest Tests</h3>
1730
+ <div id="fastestTests" class="performance-list"></div>
1731
+ </div>
1732
+ </div>
1733
+ </section>
1734
+
1735
+ <section class="history-section" style="display: {{showHistory}};">
1736
+ <h2>Test Execution History</h2>
1737
+ <div class="history-stats" id="historyStats"></div>
1738
+ <div class="history-timeline" id="historyTimeline"></div>
1739
+ <div class="history-chart-container">
1740
+ <canvas id="historyChart" width="1600" height="600"></canvas>
1741
+ </div>
1742
+ </section>
1743
+
1744
+ <section class="filters-section">
1745
+ <h2>Filters</h2>
1746
+ <div class="filter-controls">
1747
+ <div class="filter-group">
1748
+ <label>Status:</label>
1749
+ <select id="statusFilter" multiple>
1750
+ <option value="passed">Passed</option>
1751
+ <option value="failed">Failed</option>
1752
+ <option value="pending">Pending</option>
1753
+ <option value="skipped">Skipped</option>
1754
+ </select>
1755
+ </div>
1756
+ <div class="filter-group">
1757
+ <label>Feature:</label>
1758
+ <input type="text" id="featureFilter" placeholder="Filter by feature...">
1759
+ </div>
1760
+ <div class="filter-group">
1761
+ <label>Tags:</label>
1762
+ <input type="text" id="tagFilter" placeholder="Filter by tags...">
1763
+ </div>
1764
+ <div class="filter-group">
1765
+ <label>Retries:</label>
1766
+ <select id="retryFilter">
1767
+ <option value="all">All</option>
1768
+ <option value="retried">With Retries</option>
1769
+ <option value="no-retries">No Retries</option>
1770
+ </select>
1771
+ </div>
1772
+ <div class="filter-group">
1773
+ <label>Test Type:</label>
1774
+ <select id="typeFilter">
1775
+ <option value="all">All</option>
1776
+ <option value="bdd">BDD/Gherkin</option>
1777
+ <option value="regular">Regular</option>
1778
+ </select>
1779
+ </div>
1780
+ <button onclick="resetFilters()">Reset Filters</button>
1781
+ </div>
1782
+ </section>
1783
+
1784
+ <section class="tests-section">
1785
+ <h2>Test Results</h2>
1786
+ <div class="tests-container">
1787
+ {{testsHtml}}
1788
+ </div>
1789
+ </section>
1790
+
1791
+ <section class="retries-section" style="display: none;">
1792
+ <h2>Test Retries (Moved to Test Details)</h2>
1793
+ <div class="retries-container">
1794
+ <p>Retry information is now shown in each test's details section.</p>
1795
+ </div>
1796
+ </section>
1797
+
1798
+ </main>
1799
+
1800
+ <!-- Modal for images -->
1801
+ <div id="imageModal" class="modal" onclick="closeImageModal()">
1802
+ <img id="modalImage" src="" alt="Enlarged screenshot"/>
1803
+ </div>
1804
+
1805
+ <script>
1806
+ window.testData = {
1807
+ stats: {{stats}},
1808
+ history: {{history}}
1809
+ };
1810
+ </script>
1811
+ <script>{{jsScripts}}</script>
1812
+ </body>
1813
+ </html>
1814
+ `
1815
+ }
1816
+
1817
+ function getCssStyles() {
1818
+ return `
1819
+ * {
1820
+ margin: 0;
1821
+ padding: 0;
1822
+ box-sizing: border-box;
1823
+ }
1824
+
1825
+ body {
1826
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1827
+ line-height: 1.6;
1828
+ color: #333;
1829
+ background-color: #f5f5f5;
1830
+ }
1831
+
1832
+ .report-header {
1833
+ background: #2c3e50;
1834
+ color: white;
1835
+ padding: 2rem 1rem;
1836
+ text-align: center;
1837
+ }
1838
+
1839
+ .report-header h1 {
1840
+ margin-bottom: 0.5rem;
1841
+ font-size: 2.5rem;
1842
+ }
1843
+
1844
+ .report-meta {
1845
+ font-size: 0.9rem;
1846
+ opacity: 0.8;
1847
+ }
1848
+
1849
+ .report-meta span {
1850
+ margin: 0 1rem;
1851
+ }
1852
+
1853
+ .report-content {
1854
+ max-width: 1200px;
1855
+ margin: 2rem auto;
1856
+ padding: 0 1rem;
1857
+ }
1858
+
1859
+ .stats-section, .tests-section, .retries-section, .filters-section, .history-section, .system-info-section, .test-performance-section {
1860
+ background: white;
1861
+ margin-bottom: 2rem;
1862
+ border-radius: 8px;
1863
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1864
+ overflow: hidden;
1865
+ }
1866
+
1867
+ .stats-section h2, .tests-section h2, .retries-section h2, .filters-section h2, .history-section h2, .test-performance-section h2 {
1868
+ background: #34495e;
1869
+ color: white;
1870
+ padding: 1rem;
1871
+ margin: 0;
1872
+ }
1873
+
1874
+ .stats-cards {
1875
+ display: flex;
1876
+ flex-wrap: wrap;
1877
+ gap: 1rem;
1878
+ padding: 1rem;
1879
+ }
1880
+
1881
+ .stat-card {
1882
+ flex: 1;
1883
+ min-width: 150px;
1884
+ padding: 1rem;
1885
+ text-align: center;
1886
+ border-radius: 4px;
1887
+ color: white;
1888
+ }
1889
+
1890
+ .stat-card.total { background: #3498db; }
1891
+ .stat-card.passed { background: #27ae60; }
1892
+ .stat-card.failed { background: #e74c3c; }
1893
+ .stat-card.pending { background: #f39c12; }
1894
+ .stat-card.flaky { background: #e67e22; }
1895
+ .stat-card.artifacts { background: #9b59b6; }
1896
+
1897
+ .metrics-summary {
1898
+ display: flex;
1899
+ justify-content: center;
1900
+ gap: 2rem;
1901
+ padding: 1rem;
1902
+ background: #f8f9fa;
1903
+ border-radius: 6px;
1904
+ margin: 1rem 0;
1905
+ font-size: 1rem;
1906
+ }
1907
+
1908
+ .metrics-summary span {
1909
+ color: #34495e;
1910
+ }
1911
+
1912
+ .stat-card h3 {
1913
+ font-size: 0.9rem;
1914
+ margin-bottom: 0.5rem;
1915
+ }
1916
+
1917
+ .stat-number {
1918
+ font-size: 2rem;
1919
+ font-weight: bold;
1920
+ }
1921
+
1922
+ .pie-chart-container {
1923
+ display: flex;
1924
+ justify-content: center;
1925
+ align-items: center;
1926
+ padding: 2rem 1rem;
1927
+ background: white;
1928
+ margin: 1rem 0;
1929
+ border-radius: 8px;
1930
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1931
+ }
1932
+
1933
+ #statsChart {
1934
+ max-width: 100%;
1935
+ height: auto;
1936
+ }
1937
+
1938
+ .feature-group {
1939
+ margin-bottom: 2.5rem;
1940
+ border: 2px solid #3498db;
1941
+ border-radius: 12px;
1942
+ overflow: hidden;
1943
+ background: white;
1944
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
1945
+ }
1946
+
1947
+ .feature-group-title {
1948
+ background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
1949
+ color: white;
1950
+ padding: 1.2rem 1.5rem;
1951
+ margin: 0;
1952
+ font-size: 1.4rem;
1953
+ font-weight: 600;
1954
+ display: flex;
1955
+ align-items: center;
1956
+ justify-content: space-between;
1957
+ cursor: pointer;
1958
+ transition: all 0.3s ease;
1959
+ user-select: none;
1960
+ }
1961
+
1962
+ .feature-group-title:hover {
1963
+ background: linear-gradient(135deg, #2980b9 0%, #21618c 100%);
1964
+ }
1965
+
1966
+ .feature-group-title .toggle-icon {
1967
+ font-size: 1.2rem;
1968
+ transition: transform 0.3s ease;
1969
+ }
1970
+
1971
+ .feature-group-title .toggle-icon.rotated {
1972
+ transform: rotate(180deg);
1973
+ }
1974
+
1975
+ .feature-tests {
1976
+ padding: 0;
1977
+ transition: max-height 0.3s ease, opacity 0.3s ease;
1978
+ }
1979
+
1980
+ .feature-tests.collapsed {
1981
+ max-height: 0;
1982
+ opacity: 0;
1983
+ overflow: hidden;
1984
+ }
1985
+
1986
+ .test-item {
1987
+ border-bottom: 1px solid #eee;
1988
+ margin: 0;
1989
+ }
1990
+
1991
+ .test-item:last-child {
1992
+ border-bottom: none;
1993
+ }
1994
+
1995
+ .test-header {
1996
+ display: flex;
1997
+ align-items: center;
1998
+ padding: 1rem;
1999
+ cursor: pointer;
2000
+ transition: background-color 0.2s;
2001
+ }
2002
+
2003
+ .test-header:hover {
2004
+ background-color: #f8f9fa;
2005
+ }
2006
+
2007
+ .test-info {
2008
+ flex: 1;
2009
+ display: flex;
2010
+ flex-direction: column;
2011
+ gap: 0.25rem;
2012
+ }
2013
+
2014
+ .test-meta-line {
2015
+ display: flex;
2016
+ align-items: center;
2017
+ gap: 0.5rem;
2018
+ font-size: 0.9rem;
2019
+ }
2020
+
2021
+ .test-status {
2022
+ font-size: 1.2rem;
2023
+ margin-right: 0.5rem;
2024
+ }
2025
+
2026
+ .test-status.passed { color: #27ae60; }
2027
+ .test-status.failed { color: #e74c3c; }
2028
+ .test-status.pending { color: #f39c12; }
2029
+ .test-status.skipped { color: #95a5a6; }
2030
+
2031
+ .test-title {
2032
+ font-size: 1.1rem;
2033
+ font-weight: 500;
2034
+ margin: 0;
2035
+ }
2036
+
2037
+ .test-feature {
2038
+ background: #ecf0f1;
2039
+ padding: 0.25rem 0.5rem;
2040
+ border-radius: 4px;
2041
+ font-size: 0.8rem;
2042
+ color: #34495e;
2043
+ }
2044
+
2045
+ .test-uid {
2046
+ background: #e8f4fd;
2047
+ padding: 0.25rem 0.5rem;
2048
+ border-radius: 4px;
2049
+ font-size: 0.7rem;
2050
+ color: #2980b9;
2051
+ font-family: monospace;
2052
+ }
2053
+
2054
+ .retry-badge {
2055
+ background: #f39c12;
2056
+ color: white;
2057
+ padding: 0.25rem 0.5rem;
2058
+ border-radius: 4px;
2059
+ font-size: 0.7rem;
2060
+ font-weight: bold;
2061
+ }
2062
+
2063
+ .worker-badge {
2064
+ background: #16a085;
2065
+ color: white;
2066
+ padding: 0.25rem 0.5rem;
2067
+ border-radius: 4px;
2068
+ font-size: 0.7rem;
2069
+ font-weight: bold;
2070
+ }
2071
+
2072
+ /* Different colors for each worker index */
2073
+ .worker-badge.worker-0 {
2074
+ background: #3498db; /* Blue */
2075
+ }
2076
+
2077
+ .worker-badge.worker-1 {
2078
+ background: #e74c3c; /* Red */
2079
+ }
2080
+
2081
+ .worker-badge.worker-2 {
2082
+ background: #2ecc71; /* Green */
2083
+ }
2084
+
2085
+ .worker-badge.worker-3 {
2086
+ background: #f39c12; /* Orange */
2087
+ }
2088
+
2089
+ .worker-badge.worker-4 {
2090
+ background: #9b59b6; /* Purple */
2091
+ }
2092
+
2093
+ .worker-badge.worker-5 {
2094
+ background: #1abc9c; /* Turquoise */
2095
+ }
2096
+
2097
+ .worker-badge.worker-6 {
2098
+ background: #e67e22; /* Carrot */
2099
+ }
2100
+
2101
+ .worker-badge.worker-7 {
2102
+ background: #34495e; /* Dark Blue-Gray */
2103
+ }
2104
+
2105
+ .worker-badge.worker-8 {
2106
+ background: #16a085; /* Teal */
2107
+ }
2108
+
2109
+ .worker-badge.worker-9 {
2110
+ background: #c0392b; /* Dark Red */
2111
+ }
2112
+
2113
+ .test-duration {
2114
+ font-size: 0.85rem;
2115
+ font-weight: 600;
2116
+ color: #2c3e50;
2117
+ background: #ecf0f1;
2118
+ padding: 0.25rem 0.5rem;
2119
+ border-radius: 4px;
2120
+ font-family: 'Monaco', 'Courier New', monospace;
2121
+ }
2122
+
2123
+ .test-details {
2124
+ display: none;
2125
+ padding: 1rem;
2126
+ background: #f8f9fa;
2127
+ border-top: 1px solid #e9ecef;
2128
+ }
2129
+
2130
+ .error-message {
2131
+ background: #fee;
2132
+ border: 1px solid #fcc;
2133
+ border-radius: 4px;
2134
+ padding: 1rem;
2135
+ margin-bottom: 1rem;
2136
+ }
2137
+
2138
+ .error-message pre {
2139
+ color: #c0392b;
2140
+ font-family: 'Courier New', monospace;
2141
+ font-size: 0.9rem;
2142
+ white-space: pre-wrap;
2143
+ word-wrap: break-word;
2144
+ }
2145
+
2146
+ .steps-section, .artifacts-section, .hooks-section {
2147
+ margin-top: 1rem;
2148
+ }
2149
+
2150
+ .steps-section h4, .artifacts-section h4, .hooks-section h4 {
2151
+ color: #34495e;
2152
+ margin-bottom: 0.5rem;
2153
+ font-size: 1rem;
2154
+ }
2155
+
2156
+ .hook-item {
2157
+ display: flex;
2158
+ align-items: flex-start;
2159
+ padding: 0.75rem;
2160
+ border: 1px solid #ecf0f1;
2161
+ border-radius: 4px;
2162
+ margin-bottom: 0.5rem;
2163
+ background: #fafafa;
2164
+ }
2165
+
2166
+ .hook-item:last-child {
2167
+ margin-bottom: 0;
2168
+ }
2169
+
2170
+ .hook-status {
2171
+ margin-right: 0.75rem;
2172
+ flex-shrink: 0;
2173
+ margin-top: 0.2rem;
2174
+ }
2175
+
2176
+ .hook-status.passed { color: #27ae60; }
2177
+ .hook-status.failed { color: #e74c3c; }
2178
+
2179
+ .hook-content {
2180
+ flex: 1;
2181
+ display: flex;
2182
+ flex-direction: column;
2183
+ gap: 0.25rem;
2184
+ }
2185
+
2186
+ .hook-title {
2187
+ font-family: 'Courier New', monospace;
2188
+ font-size: 0.9rem;
2189
+ font-weight: bold;
2190
+ color: #2c3e50;
2191
+ }
2192
+
2193
+ .hook-duration {
2194
+ font-size: 0.8rem;
2195
+ color: #7f8c8d;
2196
+ }
2197
+
2198
+ .hook-location, .hook-context {
2199
+ font-size: 0.8rem;
2200
+ color: #6c757d;
2201
+ font-style: italic;
2202
+ }
2203
+
2204
+ .hook-error {
2205
+ margin-top: 0.5rem;
2206
+ padding: 0.5rem;
2207
+ background: #fee;
2208
+ border: 1px solid #fcc;
2209
+ border-radius: 4px;
2210
+ color: #c0392b;
2211
+ font-size: 0.8rem;
2212
+ }
2213
+
2214
+ .step-item {
2215
+ display: flex;
2216
+ align-items: flex-start;
2217
+ padding: 0.5rem 0;
2218
+ border-bottom: 1px solid #ecf0f1;
2219
+ word-wrap: break-word;
2220
+ overflow-wrap: break-word;
2221
+ min-height: 2rem;
2222
+ }
2223
+
2224
+ .step-item:last-child {
2225
+ border-bottom: none;
2226
+ }
2227
+
2228
+ .step-status {
2229
+ margin-right: 0.5rem;
2230
+ flex-shrink: 0;
2231
+ margin-top: 0.2rem;
2232
+ }
2233
+
2234
+ .step-status.success { color: #27ae60; }
2235
+ .step-status.failed { color: #e74c3c; }
2236
+
2237
+ .step-title {
2238
+ flex: 1;
2239
+ font-family: 'Courier New', monospace;
2240
+ font-size: 0.9rem;
2241
+ word-wrap: break-word;
2242
+ overflow-wrap: break-word;
2243
+ line-height: 1.4;
2244
+ margin-right: 0.5rem;
2245
+ min-width: 0;
2246
+ }
2247
+
2248
+ .step-duration {
2249
+ font-size: 0.8rem;
2250
+ color: #7f8c8d;
2251
+ flex-shrink: 0;
2252
+ margin-top: 0.2rem;
2253
+ }
2254
+
2255
+ .artifacts-list {
2256
+ display: flex;
2257
+ flex-wrap: wrap;
2258
+ gap: 0.5rem;
2259
+ }
2260
+
2261
+ .artifact-image {
2262
+ max-width: 200px;
2263
+ max-height: 150px;
2264
+ border: 1px solid #ddd;
2265
+ border-radius: 4px;
2266
+ cursor: pointer;
2267
+ transition: transform 0.2s;
2268
+ }
2269
+
2270
+ .artifact-image:hover {
2271
+ transform: scale(1.05);
2272
+ }
2273
+
2274
+ .artifact-item {
2275
+ background: #ecf0f1;
2276
+ padding: 0.5rem;
2277
+ border-radius: 4px;
2278
+ font-size: 0.9rem;
2279
+ }
2280
+
2281
+ .modal {
2282
+ display: none;
2283
+ position: fixed;
2284
+ z-index: 1000;
2285
+ left: 0;
2286
+ top: 0;
2287
+ width: 100%;
2288
+ height: 100%;
2289
+ background-color: rgba(0,0,0,0.8);
2290
+ cursor: pointer;
2291
+ }
2292
+
2293
+ .modal img {
2294
+ position: absolute;
2295
+ top: 50%;
2296
+ left: 50%;
2297
+ transform: translate(-50%, -50%);
2298
+ max-width: 90%;
2299
+ max-height: 90%;
2300
+ border-radius: 4px;
2301
+ }
2302
+
2303
+ /* Enhanced screenshot styles for failed tests */
2304
+ .screenshots-section {
2305
+ margin-top: 1rem;
2306
+ }
2307
+
2308
+ .screenshots-section h4 {
2309
+ color: #e74c3c;
2310
+ margin-bottom: 0.75rem;
2311
+ font-size: 1rem;
2312
+ font-weight: 600;
2313
+ }
2314
+
2315
+ .screenshots-list {
2316
+ display: flex;
2317
+ flex-direction: column;
2318
+ gap: 1rem;
2319
+ }
2320
+
2321
+ .screenshot-container {
2322
+ border: 2px solid #e74c3c;
2323
+ border-radius: 8px;
2324
+ overflow: hidden;
2325
+ background: white;
2326
+ box-shadow: 0 4px 8px rgba(231, 76, 60, 0.1);
2327
+ }
2328
+
2329
+ .screenshot-header {
2330
+ background: #e74c3c;
2331
+ color: white;
2332
+ padding: 0.5rem 1rem;
2333
+ font-size: 0.9rem;
2334
+ font-weight: 500;
2335
+ }
2336
+
2337
+ .screenshot-label {
2338
+ display: flex;
2339
+ align-items: center;
2340
+ gap: 0.5rem;
2341
+ }
2342
+
2343
+ .failure-screenshot {
2344
+ width: 100%;
2345
+ max-width: 100%;
2346
+ height: auto;
2347
+ display: block;
2348
+ cursor: pointer;
2349
+ transition: opacity 0.2s;
2350
+ }
2351
+
2352
+ .failure-screenshot:hover {
2353
+ opacity: 0.9;
2354
+ }
2355
+
2356
+ .other-artifacts-section {
2357
+ margin-top: 1rem;
2358
+ }
2359
+
2360
+ /* Filter Controls */
2361
+ .filter-controls {
2362
+ display: flex;
2363
+ flex-wrap: wrap;
2364
+ gap: 1rem;
2365
+ padding: 1rem;
2366
+ background: #f8f9fa;
2367
+ }
2368
+
2369
+ .filter-group {
2370
+ display: flex;
2371
+ flex-direction: column;
2372
+ gap: 0.25rem;
2373
+ }
2374
+
2375
+ .filter-group label {
2376
+ font-size: 0.9rem;
2377
+ font-weight: 500;
2378
+ color: #34495e;
2379
+ }
2380
+
2381
+ .filter-group input,
2382
+ .filter-group select {
2383
+ padding: 0.5rem;
2384
+ border: 1px solid #ddd;
2385
+ border-radius: 4px;
2386
+ font-size: 0.9rem;
2387
+ min-width: 150px;
2388
+ }
2389
+
2390
+ .filter-group select[multiple] {
2391
+ height: auto;
2392
+ min-height: 80px;
2393
+ }
2394
+
2395
+ .filter-controls button {
2396
+ padding: 0.5rem 1rem;
2397
+ background: #3498db;
2398
+ color: white;
2399
+ border: none;
2400
+ border-radius: 4px;
2401
+ cursor: pointer;
2402
+ font-size: 0.9rem;
2403
+ align-self: flex-end;
2404
+ }
2405
+
2406
+ .filter-controls button:hover {
2407
+ background: #2980b9;
2408
+ }
2409
+
2410
+ /* Test Tags */
2411
+ .tags-section, .metadata-section, .notes-section, .retry-section {
2412
+ margin-top: 1rem;
2413
+ }
2414
+
2415
+ .tags-list {
2416
+ display: flex;
2417
+ flex-wrap: wrap;
2418
+ gap: 0.5rem;
2419
+ }
2420
+
2421
+ .test-tag {
2422
+ background: #3498db;
2423
+ color: white;
2424
+ padding: 0.25rem 0.5rem;
2425
+ border-radius: 12px;
2426
+ font-size: 0.8rem;
2427
+ }
2428
+
2429
+ /* Metadata */
2430
+ .metadata-list {
2431
+ display: flex;
2432
+ flex-direction: column;
2433
+ gap: 0.5rem;
2434
+ }
2435
+
2436
+ .meta-item {
2437
+ padding: 0.5rem;
2438
+ background: #f8f9fa;
2439
+ border-radius: 4px;
2440
+ border-left: 3px solid #3498db;
2441
+ }
2442
+
2443
+ .meta-key {
2444
+ font-weight: bold;
2445
+ color: #2c3e50;
2446
+ }
2447
+
2448
+ .meta-value {
2449
+ color: #34495e;
2450
+ font-family: monospace;
2451
+ }
2452
+
2453
+ /* Notes */
2454
+ .notes-list {
2455
+ display: flex;
2456
+ flex-direction: column;
2457
+ gap: 0.5rem;
2458
+ }
2459
+
2460
+ .note-item {
2461
+ padding: 0.5rem;
2462
+ border-radius: 4px;
2463
+ border-left: 3px solid #95a5a6;
2464
+ }
2465
+
2466
+ .note-item.note-info {
2467
+ background: #e8f4fd;
2468
+ border-left-color: #3498db;
2469
+ }
2470
+
2471
+ .note-item.note-warning {
2472
+ background: #fef9e7;
2473
+ border-left-color: #f39c12;
2474
+ }
2475
+
2476
+ .note-item.note-error {
2477
+ background: #fee;
2478
+ border-left-color: #e74c3c;
2479
+ }
2480
+
2481
+ .note-item.note-retry {
2482
+ background: #f0f8e8;
2483
+ border-left-color: #27ae60;
2484
+ }
2485
+
2486
+ .note-type {
2487
+ font-weight: bold;
2488
+ text-transform: uppercase;
2489
+ font-size: 0.8rem;
2490
+ }
2491
+
2492
+ /* Retry Information */
2493
+ .retry-section {
2494
+ margin-top: 1rem;
2495
+ }
2496
+
2497
+ .retry-info {
2498
+ padding: 1rem;
2499
+ background: #fff9e6;
2500
+ border-radius: 4px;
2501
+ border-left: 4px solid #f39c12;
2502
+ }
2503
+
2504
+ .retry-summary {
2505
+ display: flex;
2506
+ align-items: center;
2507
+ gap: 1rem;
2508
+ margin-bottom: 0.5rem;
2509
+ }
2510
+
2511
+ .retry-count {
2512
+ color: #d68910;
2513
+ font-weight: 500;
2514
+ }
2515
+
2516
+ .retry-status-badge {
2517
+ padding: 0.25rem 0.75rem;
2518
+ border-radius: 4px;
2519
+ font-size: 0.85rem;
2520
+ font-weight: bold;
2521
+ }
2522
+
2523
+ .retry-status-badge.passed {
2524
+ background: #27ae60;
2525
+ color: white;
2526
+ }
2527
+
2528
+ .retry-status-badge.failed {
2529
+ background: #e74c3c;
2530
+ color: white;
2531
+ }
2532
+
2533
+ .retry-description {
2534
+ font-size: 0.9rem;
2535
+ color: #6c757d;
2536
+ font-style: italic;
2537
+ }
2538
+
2539
+ /* Retries Section */
2540
+ .retry-item {
2541
+ padding: 1rem;
2542
+ margin-bottom: 1rem;
2543
+ border: 1px solid #f39c12;
2544
+ border-radius: 4px;
2545
+ background: #fef9e7;
2546
+ }
2547
+
2548
+ .retry-item h4 {
2549
+ color: #d68910;
2550
+ margin-bottom: 0.5rem;
2551
+ }
2552
+
2553
+ .retry-details {
2554
+ display: flex;
2555
+ gap: 1rem;
2556
+ align-items: center;
2557
+ font-size: 0.9rem;
2558
+ }
2559
+
2560
+ .status-badge {
2561
+ padding: 0.25rem 0.5rem;
2562
+ border-radius: 4px;
2563
+ font-size: 0.8rem;
2564
+ font-weight: bold;
2565
+ text-transform: uppercase;
2566
+ }
2567
+
2568
+ .status-badge.passed {
2569
+ background: #27ae60;
2570
+ color: white;
2571
+ }
2572
+
2573
+ .status-badge.failed {
2574
+ background: #e74c3c;
2575
+ color: white;
2576
+ }
2577
+
2578
+ .status-badge.pending {
2579
+ background: #f39c12;
2580
+ color: white;
2581
+ }
2582
+
2583
+ /* History Chart */
2584
+ .history-stats {
2585
+ padding: 1.5rem;
2586
+ background: #f8f9fa;
2587
+ border-bottom: 1px solid #e9ecef;
2588
+ }
2589
+
2590
+ .history-stats-grid {
2591
+ display: grid;
2592
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2593
+ gap: 1rem;
2594
+ }
2595
+
2596
+ .history-stat-item {
2597
+ background: white;
2598
+ padding: 1rem;
2599
+ border-radius: 6px;
2600
+ border-left: 4px solid #3498db;
2601
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
2602
+ }
2603
+
2604
+ .history-stat-item h4 {
2605
+ margin: 0 0 0.5rem 0;
2606
+ font-size: 0.9rem;
2607
+ color: #7f8c8d;
2608
+ text-transform: uppercase;
2609
+ }
2610
+
2611
+ .history-stat-item .value {
2612
+ font-size: 1.5rem;
2613
+ font-weight: bold;
2614
+ color: #2c3e50;
2615
+ }
2616
+
2617
+ .history-timeline {
2618
+ padding: 1.5rem;
2619
+ background: white;
2620
+ }
2621
+
2622
+ .timeline-item {
2623
+ display: flex;
2624
+ align-items: center;
2625
+ padding: 0.75rem;
2626
+ border-left: 3px solid #3498db;
2627
+ margin-left: 1rem;
2628
+ margin-bottom: 0.5rem;
2629
+ background: #f8f9fa;
2630
+ border-radius: 0 6px 6px 0;
2631
+ transition: all 0.2s;
2632
+ }
2633
+
2634
+ .timeline-item:hover {
2635
+ background: #e9ecef;
2636
+ transform: translateX(4px);
2637
+ }
2638
+
2639
+ .timeline-time {
2640
+ min-width: 150px;
2641
+ font-weight: 600;
2642
+ color: #2c3e50;
2643
+ font-family: 'Courier New', monospace;
2644
+ }
2645
+
2646
+ .timeline-result {
2647
+ flex: 1;
2648
+ display: flex;
2649
+ gap: 1rem;
2650
+ align-items: center;
2651
+ }
2652
+
2653
+ .timeline-badge {
2654
+ padding: 0.25rem 0.5rem;
2655
+ border-radius: 4px;
2656
+ font-size: 0.85rem;
2657
+ font-weight: 600;
2658
+ }
2659
+
2660
+ .timeline-badge.success {
2661
+ background: #d4edda;
2662
+ color: #155724;
2663
+ }
2664
+
2665
+ .timeline-badge.failure {
2666
+ background: #f8d7da;
2667
+ color: #721c24;
2668
+ }
2669
+
2670
+ .history-chart-container {
2671
+ padding: 2rem 1rem;
2672
+ display: flex;
2673
+ justify-content: center;
2674
+ }
2675
+
2676
+ #historyChart {
2677
+ max-width: 100%;
2678
+ height: auto;
2679
+ }
2680
+
2681
+ /* Test Performance Section */
2682
+ .performance-container {
2683
+ display: grid;
2684
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
2685
+ gap: 2rem;
2686
+ padding: 1.5rem;
2687
+ }
2688
+
2689
+ .performance-group h3 {
2690
+ margin: 0 0 1rem 0;
2691
+ color: #2c3e50;
2692
+ font-size: 1.1rem;
2693
+ padding-bottom: 0.5rem;
2694
+ border-bottom: 2px solid #3498db;
2695
+ }
2696
+
2697
+ .performance-list {
2698
+ display: flex;
2699
+ flex-direction: column;
2700
+ gap: 0.75rem;
2701
+ }
2702
+
2703
+ .performance-item {
2704
+ display: flex;
2705
+ align-items: center;
2706
+ justify-content: space-between;
2707
+ padding: 0.75rem 1rem;
2708
+ background: #f8f9fa;
2709
+ border-radius: 6px;
2710
+ border-left: 4px solid #3498db;
2711
+ transition: all 0.2s;
2712
+ }
2713
+
2714
+ .performance-item:hover {
2715
+ background: #e9ecef;
2716
+ transform: translateX(4px);
2717
+ }
2718
+
2719
+ .performance-item:nth-child(1) .performance-rank {
2720
+ background: #f39c12;
2721
+ color: white;
2722
+ }
2723
+
2724
+ .performance-item:nth-child(2) .performance-rank {
2725
+ background: #95a5a6;
2726
+ color: white;
2727
+ }
2728
+
2729
+ .performance-item:nth-child(3) .performance-rank {
2730
+ background: #cd7f32;
2731
+ color: white;
2732
+ }
2733
+
2734
+ .performance-rank {
2735
+ display: flex;
2736
+ align-items: center;
2737
+ justify-content: center;
2738
+ width: 28px;
2739
+ height: 28px;
2740
+ background: #3498db;
2741
+ color: white;
2742
+ border-radius: 50%;
2743
+ font-weight: bold;
2744
+ font-size: 0.9rem;
2745
+ margin-right: 1rem;
2746
+ flex-shrink: 0;
2747
+ }
2748
+
2749
+ .performance-name {
2750
+ flex: 1;
2751
+ font-weight: 500;
2752
+ color: #2c3e50;
2753
+ }
2754
+
2755
+ .performance-duration {
2756
+ font-weight: 600;
2757
+ color: #7f8c8d;
2758
+ font-family: 'Courier New', monospace;
2759
+ font-size: 0.9rem;
2760
+ }
2761
+
2762
+ /* Hidden items for filtering */
2763
+ .test-item.filtered-out {
2764
+ display: none !important;
2765
+ }
2766
+
2767
+ /* System Info Section */
2768
+ .system-info-section {
2769
+ background: white;
2770
+ margin-bottom: 2rem;
2771
+ border-radius: 8px;
2772
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
2773
+ overflow: hidden;
2774
+ }
2775
+
2776
+ .system-info-header {
2777
+ background: #2c3e50;
2778
+ color: white;
2779
+ padding: 1rem;
2780
+ cursor: pointer;
2781
+ display: flex;
2782
+ justify-content: space-between;
2783
+ align-items: center;
2784
+ transition: background-color 0.2s;
2785
+ }
2786
+
2787
+ .system-info-header:hover {
2788
+ background: #34495e;
2789
+ }
2790
+
2791
+ .system-info-header h3 {
2792
+ margin: 0;
2793
+ font-size: 1.2rem;
2794
+ }
2795
+
2796
+ .toggle-icon {
2797
+ font-size: 1rem;
2798
+ transition: transform 0.3s ease;
2799
+ }
2800
+
2801
+ .toggle-icon.rotated {
2802
+ transform: rotate(-180deg);
2803
+ }
2804
+
2805
+ .system-info-content {
2806
+ display: none;
2807
+ padding: 1.5rem;
2808
+ background: #f8f9fa;
2809
+ border-top: 1px solid #e9ecef;
2810
+ }
2811
+
2812
+ .system-info-content.visible {
2813
+ display: block;
2814
+ }
2815
+
2816
+ .system-info-grid {
2817
+ display: grid;
2818
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
2819
+ gap: 1rem;
2820
+ }
2821
+
2822
+ .info-item {
2823
+ padding: 0.75rem;
2824
+ background: white;
2825
+ border-radius: 6px;
2826
+ border-left: 4px solid #3498db;
2827
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
2828
+ }
2829
+
2830
+ .info-key {
2831
+ font-weight: bold;
2832
+ color: #2c3e50;
2833
+ display: inline-block;
2834
+ min-width: 100px;
2835
+ }
2836
+
2837
+ .info-value {
2838
+ color: #34495e;
2839
+ font-family: 'Courier New', monospace;
2840
+ font-size: 0.9rem;
2841
+ }
2842
+
2843
+ /* BDD/Gherkin specific styles */
2844
+ .bdd-test {
2845
+ border-left: 4px solid #8e44ad;
2846
+ }
2847
+
2848
+ .bdd-badge {
2849
+ background: #8e44ad;
2850
+ color: white;
2851
+ padding: 0.25rem 0.5rem;
2852
+ border-radius: 4px;
2853
+ font-size: 0.7rem;
2854
+ font-weight: bold;
2855
+ }
2856
+
2857
+ .bdd-feature-section {
2858
+ margin-top: 1rem;
2859
+ padding: 1rem;
2860
+ background: #f8f9fa;
2861
+ border-left: 4px solid #8e44ad;
2862
+ border-radius: 4px;
2863
+ }
2864
+
2865
+ .feature-name {
2866
+ font-weight: bold;
2867
+ font-size: 1.1rem;
2868
+ color: #8e44ad;
2869
+ margin-bottom: 0.5rem;
2870
+ }
2871
+
2872
+ .feature-description {
2873
+ color: #34495e;
2874
+ font-style: italic;
2875
+ margin: 0.5rem 0;
2876
+ padding: 0.5rem;
2877
+ background: white;
2878
+ border-radius: 4px;
2879
+ }
2880
+
2881
+ .feature-file {
2882
+ font-size: 0.8rem;
2883
+ color: #7f8c8d;
2884
+ margin-top: 0.5rem;
2885
+ }
2886
+
2887
+ .feature-tags {
2888
+ display: flex;
2889
+ flex-wrap: wrap;
2890
+ gap: 0.25rem;
2891
+ margin: 0.5rem 0;
2892
+ }
2893
+
2894
+ .feature-tag {
2895
+ background: #8e44ad;
2896
+ color: white;
2897
+ padding: 0.2rem 0.4rem;
2898
+ border-radius: 8px;
2899
+ font-size: 0.7rem;
2900
+ }
2901
+
2902
+ .bdd-steps-section {
2903
+ margin-top: 1rem;
2904
+ }
2905
+
2906
+ .bdd-steps-section h4 {
2907
+ color: #8e44ad;
2908
+ margin-bottom: 0.5rem;
2909
+ font-size: 1rem;
2910
+ }
2911
+
2912
+ .bdd-step-item {
2913
+ display: flex;
2914
+ align-items: flex-start;
2915
+ padding: 0.5rem 0;
2916
+ border-bottom: 1px solid #ecf0f1;
2917
+ font-family: 'Segoe UI', sans-serif;
2918
+ word-wrap: break-word;
2919
+ overflow-wrap: break-word;
2920
+ min-height: 2rem;
2921
+ }
2922
+
2923
+ .bdd-step-item:last-child {
2924
+ border-bottom: none;
2925
+ }
2926
+
2927
+ .bdd-keyword {
2928
+ font-weight: bold;
2929
+ color: #8e44ad;
2930
+ margin-right: 0.5rem;
2931
+ min-width: 60px;
2932
+ text-align: left;
2933
+ flex-shrink: 0;
2934
+ }
2935
+
2936
+ .bdd-step-text {
2937
+ flex: 1;
2938
+ color: #2c3e50;
2939
+ margin-right: 0.5rem;
2940
+ word-wrap: break-word;
2941
+ overflow-wrap: break-word;
2942
+ line-height: 1.4;
2943
+ min-width: 0;
2944
+ }
2945
+
2946
+ .step-comment {
2947
+ width: 100%;
2948
+ margin-top: 0.5rem;
2949
+ padding: 0.5rem;
2950
+ background: #f8f9fa;
2951
+ border-left: 3px solid #8e44ad;
2952
+ font-style: italic;
2953
+ color: #6c757d;
2954
+ word-wrap: break-word;
2955
+ overflow-wrap: break-word;
2956
+ line-height: 1.4;
2957
+ }
2958
+
2959
+ @media (max-width: 768px) {
2960
+ .stats-cards {
2961
+ flex-direction: column;
2962
+ }
2963
+
2964
+ .test-header {
2965
+ flex-direction: column;
2966
+ align-items: stretch;
2967
+ gap: 0.5rem;
2968
+ }
2969
+
2970
+ .test-feature, .test-duration {
2971
+ align-self: flex-start;
2972
+ }
2973
+ }
2974
+ `
2975
+ }
2976
+
2977
+ function getJsScripts() {
2978
+ return `
2979
+ // Go to Top button
2980
+ function scrollToTop() {
2981
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2982
+ }
2983
+
2984
+ function toggleFeatureGroup(featureId) {
2985
+ const featureTests = document.getElementById('feature-' + featureId);
2986
+ const titleElement = featureTests.previousElementSibling;
2987
+ const icon = titleElement.querySelector('.toggle-icon');
2988
+
2989
+ if (featureTests.classList.contains('collapsed')) {
2990
+ featureTests.classList.remove('collapsed');
2991
+ icon.classList.remove('rotated');
2992
+ } else {
2993
+ featureTests.classList.add('collapsed');
2994
+ icon.classList.add('rotated');
2995
+ }
2996
+ }
2997
+
2998
+ function toggleTestDetails(testId) {
2999
+ const details = document.getElementById('details-' + testId);
3000
+ if (details.style.display === 'none' || details.style.display === '') {
3001
+ details.style.display = 'block';
3002
+ } else {
3003
+ details.style.display = 'none';
3004
+ }
3005
+ }
3006
+
3007
+ function openImageModal(src) {
3008
+ const modal = document.getElementById('imageModal');
3009
+ const modalImg = document.getElementById('modalImage');
3010
+ modalImg.src = src;
3011
+ modal.style.display = 'block';
3012
+ }
3013
+
3014
+ function closeImageModal() {
3015
+ const modal = document.getElementById('imageModal');
3016
+ modal.style.display = 'none';
3017
+ }
3018
+
3019
+ function toggleSystemInfo() {
3020
+ const content = document.getElementById('systemInfoContent');
3021
+ const icon = document.querySelector('.toggle-icon');
3022
+
3023
+ if (content.classList.contains('visible')) {
3024
+ content.classList.remove('visible');
3025
+ icon.classList.remove('rotated');
3026
+ } else {
3027
+ content.classList.add('visible');
3028
+ icon.classList.add('rotated');
3029
+ }
3030
+ }
3031
+
3032
+ // Filter functionality
3033
+ function applyFilters() {
3034
+ const statusFilter = Array.from(document.getElementById('statusFilter').selectedOptions).map(opt => opt.value);
3035
+ const featureFilter = document.getElementById('featureFilter').value.toLowerCase();
3036
+ const tagFilter = document.getElementById('tagFilter').value.toLowerCase();
3037
+ const retryFilter = document.getElementById('retryFilter').value;
3038
+ const typeFilter = document.getElementById('typeFilter').value;
3039
+
3040
+ const testItems = document.querySelectorAll('.test-item');
3041
+
3042
+ testItems.forEach(item => {
3043
+ let shouldShow = true;
3044
+
3045
+ // Status filter
3046
+ if (statusFilter.length > 0) {
3047
+ const testStatus = item.dataset.status;
3048
+ if (!statusFilter.includes(testStatus)) {
3049
+ shouldShow = false;
3050
+ }
3051
+ }
3052
+
3053
+ // Feature filter
3054
+ if (featureFilter && shouldShow) {
3055
+ const feature = (item.dataset.feature || '').toLowerCase();
3056
+ if (!feature.includes(featureFilter)) {
3057
+ shouldShow = false;
3058
+ }
3059
+ }
3060
+
3061
+ // Tag filter
3062
+ if (tagFilter && shouldShow) {
3063
+ const tags = (item.dataset.tags || '').toLowerCase();
3064
+ if (!tags.includes(tagFilter)) {
3065
+ shouldShow = false;
3066
+ }
3067
+ }
3068
+
3069
+ // Retry filter
3070
+ if (retryFilter !== 'all' && shouldShow) {
3071
+ const retries = parseInt(item.dataset.retries || '0');
3072
+ if (retryFilter === 'retried' && retries === 0) {
3073
+ shouldShow = false;
3074
+ } else if (retryFilter === 'no-retries' && retries > 0) {
3075
+ shouldShow = false;
3076
+ }
3077
+ }
3078
+
3079
+ // Test type filter (BDD/Gherkin vs Regular)
3080
+ if (typeFilter !== 'all' && shouldShow) {
3081
+ const testType = item.dataset.type || 'regular';
3082
+ if (typeFilter !== testType) {
3083
+ shouldShow = false;
3084
+ }
3085
+ }
3086
+
3087
+ if (shouldShow) {
3088
+ item.classList.remove('filtered-out');
3089
+ } else {
3090
+ item.classList.add('filtered-out');
3091
+ }
3092
+ });
3093
+
3094
+ updateFilteredStats();
3095
+ }
3096
+
3097
+ function resetFilters() {
3098
+ document.getElementById('statusFilter').selectedIndex = -1;
3099
+ document.getElementById('featureFilter').value = '';
3100
+ document.getElementById('tagFilter').value = '';
3101
+ document.getElementById('retryFilter').value = 'all';
3102
+ document.getElementById('typeFilter').value = 'all';
3103
+
3104
+ document.querySelectorAll('.test-item').forEach(item => {
3105
+ item.classList.remove('filtered-out');
3106
+ });
3107
+
3108
+ updateFilteredStats();
3109
+ }
3110
+
3111
+ function updateFilteredStats() {
3112
+ const visibleTests = document.querySelectorAll('.test-item:not(.filtered-out)');
3113
+ const totalVisible = visibleTests.length;
3114
+
3115
+ // Update the title to show filtered count
3116
+ const testsSection = document.querySelector('.tests-section h2');
3117
+ const totalTests = document.querySelectorAll('.test-item').length;
3118
+
3119
+ if (totalVisible !== totalTests) {
3120
+ testsSection.textContent = 'Test Results (' + totalVisible + ' of ' + totalTests + ' shown)';
3121
+ } else {
3122
+ testsSection.textContent = 'Test Results';
3123
+ }
3124
+ }
3125
+
3126
+ // Draw pie chart using canvas
3127
+ function drawPieChart() {
3128
+ const canvas = document.getElementById('statsChart');
3129
+ if (!canvas) return;
3130
+
3131
+ const ctx = canvas.getContext('2d');
3132
+ const data = window.chartData;
3133
+
3134
+ if (!data) return;
3135
+
3136
+ const centerX = canvas.width / 2;
3137
+ const centerY = canvas.height / 2;
3138
+ const radius = Math.min(centerX, centerY) - 20;
3139
+
3140
+ const total = data.passed + data.failed + data.pending;
3141
+ if (total === 0) {
3142
+ // Draw empty circle for no tests
3143
+ ctx.strokeStyle = '#ddd';
3144
+ ctx.lineWidth = 2;
3145
+ ctx.beginPath();
3146
+ ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
3147
+ ctx.stroke();
3148
+ ctx.fillStyle = '#888';
3149
+ ctx.font = '16px Arial';
3150
+ ctx.textAlign = 'center';
3151
+ ctx.fillText('No Tests', centerX, centerY);
3152
+ return;
3153
+ }
3154
+
3155
+ let currentAngle = -Math.PI / 2; // Start from top
3156
+
3157
+ // Calculate percentages
3158
+ const passedPercent = Math.round((data.passed / total) * 100);
3159
+ const failedPercent = Math.round((data.failed / total) * 100);
3160
+ const pendingPercent = Math.round((data.pending / total) * 100);
3161
+
3162
+ // Draw passed segment
3163
+ if (data.passed > 0) {
3164
+ const angle = (data.passed / total) * 2 * Math.PI;
3165
+ ctx.beginPath();
3166
+ ctx.moveTo(centerX, centerY);
3167
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
3168
+ ctx.closePath();
3169
+ ctx.fillStyle = '#27ae60';
3170
+ ctx.fill();
3171
+
3172
+ // Add percentage text on segment if significant enough
3173
+ if (passedPercent >= 10) {
3174
+ const textAngle = currentAngle + angle / 2;
3175
+ const textRadius = radius * 0.7;
3176
+ const textX = centerX + Math.cos(textAngle) * textRadius;
3177
+ const textY = centerY + Math.sin(textAngle) * textRadius;
3178
+
3179
+ ctx.fillStyle = '#fff';
3180
+ ctx.font = 'bold 14px Arial';
3181
+ ctx.textAlign = 'center';
3182
+ ctx.textBaseline = 'middle';
3183
+ ctx.fillText(passedPercent + '%', textX, textY);
3184
+ }
3185
+
3186
+ currentAngle += angle;
3187
+ }
3188
+
3189
+ // Draw failed segment
3190
+ if (data.failed > 0) {
3191
+ const angle = (data.failed / total) * 2 * Math.PI;
3192
+ ctx.beginPath();
3193
+ ctx.moveTo(centerX, centerY);
3194
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
3195
+ ctx.closePath();
3196
+ ctx.fillStyle = '#e74c3c';
3197
+ ctx.fill();
3198
+
3199
+ // Add percentage text on segment if significant enough
3200
+ if (failedPercent >= 10) {
3201
+ const textAngle = currentAngle + angle / 2;
3202
+ const textRadius = radius * 0.7;
3203
+ const textX = centerX + Math.cos(textAngle) * textRadius;
3204
+ const textY = centerY + Math.sin(textAngle) * textRadius;
3205
+
3206
+ ctx.fillStyle = '#fff';
3207
+ ctx.font = 'bold 14px Arial';
3208
+ ctx.textAlign = 'center';
3209
+ ctx.textBaseline = 'middle';
3210
+ ctx.fillText(failedPercent + '%', textX, textY);
3211
+ }
3212
+
3213
+ currentAngle += angle;
3214
+ }
3215
+
3216
+ // Draw pending segment
3217
+ if (data.pending > 0) {
3218
+ const angle = (data.pending / total) * 2 * Math.PI;
3219
+ ctx.beginPath();
3220
+ ctx.moveTo(centerX, centerY);
3221
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
3222
+ ctx.closePath();
3223
+ ctx.fillStyle = '#f39c12';
3224
+ ctx.fill();
3225
+
3226
+ // Add percentage text on segment if significant enough
3227
+ if (pendingPercent >= 10) {
3228
+ const textAngle = currentAngle + angle / 2;
3229
+ const textRadius = radius * 0.7;
3230
+ const textX = centerX + Math.cos(textAngle) * textRadius;
3231
+ const textY = centerY + Math.sin(textAngle) * textRadius;
3232
+
3233
+ ctx.fillStyle = '#fff';
3234
+ ctx.font = 'bold 14px Arial';
3235
+ ctx.textAlign = 'center';
3236
+ ctx.textBaseline = 'middle';
3237
+ ctx.fillText(pendingPercent + '%', textX, textY);
3238
+ }
3239
+ }
3240
+
3241
+ // Add legend with percentages
3242
+ const legendY = centerY + radius + 40;
3243
+ ctx.font = '14px Arial';
3244
+ ctx.textAlign = 'left';
3245
+ ctx.textBaseline = 'alphabetic';
3246
+
3247
+ let legendX = centerX - 150;
3248
+
3249
+ // Passed legend
3250
+ ctx.fillStyle = '#27ae60';
3251
+ ctx.fillRect(legendX, legendY, 15, 15);
3252
+ ctx.fillStyle = '#333';
3253
+ ctx.fillText('Passed (' + data.passed + ' - ' + passedPercent + '%)', legendX + 20, legendY + 12);
3254
+
3255
+ // Failed legend
3256
+ legendX += 130;
3257
+ ctx.fillStyle = '#e74c3c';
3258
+ ctx.fillRect(legendX, legendY, 15, 15);
3259
+ ctx.fillStyle = '#333';
3260
+ ctx.fillText('Failed (' + data.failed + ' - ' + failedPercent + '%)', legendX + 20, legendY + 12);
3261
+
3262
+ // Pending legend
3263
+ if (data.pending > 0) {
3264
+ legendX += 120;
3265
+ ctx.fillStyle = '#f39c12';
3266
+ ctx.fillRect(legendX, legendY, 15, 15);
3267
+ ctx.fillStyle = '#333';
3268
+ ctx.fillText('Pending (' + data.pending + ' - ' + pendingPercent + '%)', legendX + 20, legendY + 12);
3269
+ }
3270
+ }
3271
+
3272
+ // Draw history chart
3273
+ function drawHistoryChart() {
3274
+ const canvas = document.getElementById('historyChart');
3275
+
3276
+ if (!canvas || !window.testData || !window.testData.history || window.testData.history.length === 0) {
3277
+ return;
3278
+ }
3279
+
3280
+ const ctx = canvas.getContext('2d');
3281
+ const history = window.testData.history.slice().reverse(); // Most recent last
3282
+ console.log('History chart - Total data points:', window.testData.history.length);
3283
+ console.log('History chart - Processing points:', history.length);
3284
+ console.log('History chart - Raw history data:', window.testData.history);
3285
+ console.log('History chart - Reversed history:', history);
3286
+
3287
+ const padding = 60;
3288
+ const bottomPadding = 80; // Extra space for timestamps
3289
+ const chartWidth = canvas.width - 2 * padding;
3290
+ const chartHeight = canvas.height - padding - bottomPadding;
3291
+
3292
+ // Clear canvas
3293
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3294
+
3295
+ // Calculate success rates and max values
3296
+ const dataPoints = history.map((run, index) => {
3297
+ const total = run.stats.tests || 0;
3298
+ const passed = run.stats.passes || 0;
3299
+ const failed = run.stats.failures || 0;
3300
+ const successRate = total > 0 ? (passed / total) * 100 : 0;
3301
+ const timestamp = new Date(run.timestamp);
3302
+
3303
+ return {
3304
+ index,
3305
+ timestamp,
3306
+ total,
3307
+ passed,
3308
+ failed,
3309
+ successRate,
3310
+ duration: run.duration || 0,
3311
+ retries: run.retries || 0
3312
+ };
3313
+ });
3314
+
3315
+ console.log('History chart - Data points created:', dataPoints.length);
3316
+ console.log('History chart - Data points:', dataPoints);
3317
+
3318
+ const maxTests = Math.max(...dataPoints.map(d => d.total));
3319
+ const maxSuccessRate = 100;
3320
+
3321
+ if (maxTests === 0) return;
3322
+
3323
+ // Draw background
3324
+ ctx.fillStyle = '#fafafa';
3325
+ ctx.fillRect(padding, padding, chartWidth, chartHeight);
3326
+
3327
+ // Draw axes
3328
+ ctx.strokeStyle = '#333';
3329
+ ctx.lineWidth = 2;
3330
+ ctx.beginPath();
3331
+ ctx.moveTo(padding, padding);
3332
+ ctx.lineTo(padding, padding + chartHeight);
3333
+ ctx.lineTo(padding + chartWidth, padding + chartHeight);
3334
+ ctx.stroke();
3335
+
3336
+ // Draw grid lines
3337
+ ctx.strokeStyle = '#e0e0e0';
3338
+ ctx.lineWidth = 1;
3339
+ for (let i = 1; i <= 4; i++) {
3340
+ const y = padding + (chartHeight * i / 4);
3341
+ ctx.beginPath();
3342
+ ctx.moveTo(padding, y);
3343
+ ctx.lineTo(padding + chartWidth, y);
3344
+ ctx.stroke();
3345
+ }
3346
+
3347
+ // Calculate positions
3348
+ const stepX = dataPoints.length > 1 ? chartWidth / (dataPoints.length - 1) : chartWidth / 2;
3349
+
3350
+ // Draw success rate area chart
3351
+ ctx.fillStyle = 'rgba(39, 174, 96, 0.1)';
3352
+ ctx.strokeStyle = '#27ae60';
3353
+ ctx.lineWidth = 3;
3354
+ ctx.beginPath();
3355
+
3356
+ dataPoints.forEach((point, index) => {
3357
+ const x = dataPoints.length === 1 ? padding + chartWidth / 2 : padding + (index * stepX);
3358
+ const y = padding + chartHeight - (point.successRate / maxSuccessRate) * chartHeight;
3359
+
3360
+ if (index === 0) {
3361
+ ctx.moveTo(x, padding + chartHeight);
3362
+ ctx.lineTo(x, y);
3363
+ } else {
3364
+ ctx.lineTo(x, y);
3365
+ }
3366
+
3367
+ point.x = x;
3368
+ point.y = y;
3369
+ });
3370
+
3371
+ // Close the area
3372
+ if (dataPoints.length > 0) {
3373
+ const lastPoint = dataPoints[dataPoints.length - 1];
3374
+ ctx.lineTo(lastPoint.x, padding + chartHeight);
3375
+ ctx.closePath();
3376
+ ctx.fill();
3377
+ }
3378
+
3379
+ // Draw success rate line
3380
+ ctx.strokeStyle = '#27ae60';
3381
+ ctx.lineWidth = 3;
3382
+ ctx.beginPath();
3383
+ dataPoints.forEach((point, index) => {
3384
+ if (index === 0) {
3385
+ ctx.moveTo(point.x, point.y);
3386
+ } else {
3387
+ ctx.lineTo(point.x, point.y);
3388
+ }
3389
+ });
3390
+ ctx.stroke();
3391
+
3392
+ // Draw data points with enhanced styling
3393
+ dataPoints.forEach(point => {
3394
+ // Outer ring based on status
3395
+ const ringColor = point.failed > 0 ? '#e74c3c' : '#27ae60';
3396
+ ctx.strokeStyle = ringColor;
3397
+ ctx.lineWidth = 3;
3398
+ ctx.beginPath();
3399
+ ctx.arc(point.x, point.y, 8, 0, 2 * Math.PI);
3400
+ ctx.stroke();
3401
+
3402
+ // Inner circle
3403
+ ctx.fillStyle = point.failed > 0 ? '#e74c3c' : '#27ae60';
3404
+ ctx.beginPath();
3405
+ ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI);
3406
+ ctx.fill();
3407
+
3408
+ // White center dot
3409
+ ctx.fillStyle = '#fff';
3410
+ ctx.beginPath();
3411
+ ctx.arc(point.x, point.y, 2, 0, 2 * Math.PI);
3412
+ ctx.fill();
3413
+ });
3414
+
3415
+ // Y-axis labels (Success Rate %)
3416
+ ctx.fillStyle = '#666';
3417
+ ctx.font = '11px Arial';
3418
+ ctx.textAlign = 'right';
3419
+ for (let i = 0; i <= 4; i++) {
3420
+ const value = Math.round((maxSuccessRate * i) / 4);
3421
+ const y = padding + chartHeight - (chartHeight * i / 4);
3422
+ ctx.fillText(value + '%', padding - 10, y + 4);
3423
+ }
3424
+
3425
+ // X-axis labels (Timestamps)
3426
+ ctx.textAlign = 'center';
3427
+ ctx.font = '10px Arial';
3428
+ dataPoints.forEach((point, index) => {
3429
+ const timeStr = point.timestamp.toLocaleTimeString('en-US', {
3430
+ hour: '2-digit',
3431
+ minute: '2-digit',
3432
+ hour12: false
3433
+ });
3434
+ const dateStr = point.timestamp.toLocaleDateString('en-US', {
3435
+ month: 'short',
3436
+ day: 'numeric'
3437
+ });
3438
+
3439
+ console.log('Drawing label ' + index + ': ' + timeStr + ' at x=' + point.x);
3440
+ ctx.fillText(timeStr, point.x, padding + chartHeight + 15);
3441
+ ctx.fillText(dateStr, point.x, padding + chartHeight + 30);
3442
+ });
3443
+
3444
+ // Enhanced legend with statistics
3445
+ const legendY = 25;
3446
+ ctx.font = '12px Arial';
3447
+ ctx.textAlign = 'left';
3448
+
3449
+ // Success rate legend
3450
+ ctx.fillStyle = '#27ae60';
3451
+ ctx.fillRect(padding + 20, legendY, 15, 15);
3452
+ ctx.fillStyle = '#333';
3453
+ ctx.fillText('Success Rate', padding + 40, legendY + 12);
3454
+
3455
+ // Current stats
3456
+ if (dataPoints.length > 0) {
3457
+ const latest = dataPoints[dataPoints.length - 1];
3458
+ const trend = dataPoints.length > 1 ?
3459
+ (latest.successRate - dataPoints[dataPoints.length - 2].successRate) : 0;
3460
+ const trendIcon = trend > 0 ? '↗' : trend < 0 ? '↘' : '→';
3461
+ const trendColor = trend > 0 ? '#27ae60' : trend < 0 ? '#e74c3c' : '#666';
3462
+
3463
+ ctx.fillStyle = '#666';
3464
+ ctx.fillText('Latest: ' + latest.successRate.toFixed(1) + '%', padding + 150, legendY + 12);
3465
+
3466
+ ctx.fillStyle = trendColor;
3467
+ ctx.fillText(trendIcon + ' ' + Math.abs(trend).toFixed(1) + '%', padding + 240, legendY + 12);
3468
+ }
3469
+
3470
+ // Chart title
3471
+ ctx.fillStyle = '#333';
3472
+ ctx.font = 'bold 14px Arial';
3473
+ ctx.textAlign = 'center';
3474
+ ctx.fillText('Test Success Rate History', canvas.width / 2, 20);
3475
+ }
3476
+
3477
+ // Initialize charts and filters
3478
+ document.addEventListener('DOMContentLoaded', function() {
3479
+
3480
+ // Draw charts
3481
+ drawPieChart();
3482
+ drawHistoryChart();
3483
+ renderTestPerformance();
3484
+ renderHistoryTimeline();
3485
+
3486
+ // Add Go to Top button
3487
+ const goTopBtn = document.createElement('button');
3488
+ goTopBtn.innerText = '↑ Top';
3489
+ goTopBtn.id = 'goTopBtn';
3490
+ goTopBtn.style.position = 'fixed';
3491
+ goTopBtn.style.bottom = '30px';
3492
+ goTopBtn.style.right = '30px';
3493
+ goTopBtn.style.zIndex = '9999';
3494
+ goTopBtn.style.padding = '12px 18px';
3495
+ goTopBtn.style.borderRadius = '50%';
3496
+ goTopBtn.style.background = '#27ae60';
3497
+ goTopBtn.style.color = '#fff';
3498
+ goTopBtn.style.fontSize = '20px';
3499
+ goTopBtn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
3500
+ goTopBtn.style.cursor = 'pointer';
3501
+ goTopBtn.onclick = scrollToTop;
3502
+ document.body.appendChild(goTopBtn);
3503
+
3504
+ // Set up filter event listeners
3505
+ document.getElementById('statusFilter').addEventListener('change', applyFilters);
3506
+ document.getElementById('featureFilter').addEventListener('input', applyFilters);
3507
+ document.getElementById('tagFilter').addEventListener('input', applyFilters);
3508
+ document.getElementById('retryFilter').addEventListener('change', applyFilters);
3509
+ document.getElementById('typeFilter').addEventListener('change', applyFilters);
3510
+ });
3511
+
3512
+ // Render test performance analysis
3513
+ function renderTestPerformance() {
3514
+ const tests = Array.from(document.querySelectorAll('.test-item'));
3515
+ const testsWithDuration = tests.map(testEl => {
3516
+ const title = testEl.querySelector('.test-title')?.textContent || 'Unknown';
3517
+ const durationText = testEl.querySelector('.test-duration')?.textContent || '0ms';
3518
+ const durationMs = parseDuration(durationText);
3519
+ const status = testEl.dataset.status;
3520
+ return { title, duration: durationMs, durationText, status };
3521
+ }); // Don't filter out 0ms tests
3522
+
3523
+ // Sort by duration
3524
+ const longest = [...testsWithDuration].sort((a, b) => b.duration - a.duration).slice(0, 5);
3525
+ const fastest = [...testsWithDuration].sort((a, b) => a.duration - b.duration).slice(0, 5);
3526
+
3527
+ // Render longest tests
3528
+ const longestContainer = document.getElementById('longestTests');
3529
+ if (longestContainer && longest.length > 0) {
3530
+ longestContainer.innerHTML = longest.map((test, index) => \`
3531
+ <div class="performance-item">
3532
+ <span class="performance-rank">\${index + 1}</span>
3533
+ <span class="performance-name" title="\${test.title}">\${test.title.length > 60 ? test.title.substring(0, 60) + '...' : test.title}</span>
3534
+ <span class="performance-duration">\${test.durationText}</span>
3535
+ </div>
3536
+ \`).join('');
3537
+ } else if (longestContainer) {
3538
+ longestContainer.innerHTML = '<p style="color: #7f8c8d; padding: 1rem;">No test data available</p>';
3539
+ }
3540
+
3541
+ // Render fastest tests
3542
+ const fastestContainer = document.getElementById('fastestTests');
3543
+ if (fastestContainer && fastest.length > 0) {
3544
+ fastestContainer.innerHTML = fastest.map((test, index) => \`
3545
+ <div class="performance-item">
3546
+ <span class="performance-rank">\${index + 1}</span>
3547
+ <span class="performance-name" title="\${test.title}">\${test.title.length > 60 ? test.title.substring(0, 60) + '...' : test.title}</span>
3548
+ <span class="performance-duration">\${test.durationText}</span>
3549
+ </div>
3550
+ \`).join('');
3551
+ } else if (fastestContainer) {
3552
+ fastestContainer.innerHTML = '<p style="color: #7f8c8d; padding: 1rem;">No test data available</p>';
3553
+ }
3554
+ }
3555
+
3556
+ // Render history timeline
3557
+ function renderHistoryTimeline() {
3558
+ if (!window.testData || !window.testData.history || window.testData.history.length === 0) {
3559
+ return;
3560
+ }
3561
+
3562
+ const history = window.testData.history.slice().reverse(); // Most recent last
3563
+
3564
+ // Render stats
3565
+ const statsContainer = document.getElementById('historyStats');
3566
+ if (statsContainer) {
3567
+ const totalRuns = history.length;
3568
+ const avgDuration = history.reduce((sum, run) => sum + (run.duration || 0), 0) / totalRuns;
3569
+ const avgTests = Math.round(history.reduce((sum, run) => sum + (run.stats.tests || 0), 0) / totalRuns);
3570
+ const avgPassRate = history.reduce((sum, run) => {
3571
+ const total = run.stats.tests || 0;
3572
+ const passed = run.stats.passes || 0;
3573
+ return sum + (total > 0 ? (passed / total) * 100 : 0);
3574
+ }, 0) / totalRuns;
3575
+
3576
+ statsContainer.innerHTML = \`
3577
+ <div class="history-stats-grid">
3578
+ <div class="history-stat-item">
3579
+ <h4>Total Runs</h4>
3580
+ <div class="value">\${totalRuns}</div>
3581
+ </div>
3582
+ <div class="history-stat-item">
3583
+ <h4>Avg Duration</h4>
3584
+ <div class="value">\${formatDuration(avgDuration)}</div>
3585
+ </div>
3586
+ <div class="history-stat-item">
3587
+ <h4>Avg Tests</h4>
3588
+ <div class="value">\${avgTests}</div>
3589
+ </div>
3590
+ <div class="history-stat-item">
3591
+ <h4>Avg Pass Rate</h4>
3592
+ <div class="value">\${avgPassRate.toFixed(1)}%</div>
3593
+ </div>
3594
+ </div>
3595
+ \`;
3596
+ }
3597
+
3598
+ // Render timeline
3599
+ const timelineContainer = document.getElementById('historyTimeline');
3600
+ if (timelineContainer) {
3601
+ const recentHistory = history.slice(-10).reverse(); // Last 10 runs, most recent first
3602
+ timelineContainer.innerHTML = '<h3 style="margin: 0 0 1rem 0; color: #2c3e50;">Recent Execution Timeline</h3>' +
3603
+ recentHistory.map(run => {
3604
+ const timestamp = new Date(run.timestamp);
3605
+ const timeStr = timestamp.toLocaleString();
3606
+ const total = run.stats.tests || 0;
3607
+ const passed = run.stats.passes || 0;
3608
+ const failed = run.stats.failures || 0;
3609
+ const badgeClass = failed > 0 ? 'failure' : 'success';
3610
+ const badgeText = failed > 0 ? \`\${failed} Failed\` : \`All Passed\`;
3611
+
3612
+ return \`
3613
+ <div class="timeline-item">
3614
+ <div class="timeline-time">\${timeStr}</div>
3615
+ <div class="timeline-result">
3616
+ <span class="timeline-badge \${badgeClass}">\${badgeText}</span>
3617
+ <span>\${passed}/\${total} passed</span>
3618
+ <span>·</span>
3619
+ <span>\${formatDuration(run.duration || 0)}</span>
3620
+ </div>
3621
+ </div>
3622
+ \`;
3623
+ }).join('');
3624
+ }
3625
+ }
3626
+
3627
+ // Helper to parse duration text to milliseconds
3628
+ function parseDuration(durationText) {
3629
+ if (!durationText) return 0;
3630
+ const match = durationText.match(/(\\d+(?:\\.\\d+)?)(ms|s|m)/);
3631
+ if (!match) return 0;
3632
+ const value = parseFloat(match[1]);
3633
+ const unit = match[2];
3634
+ if (unit === 'ms') return value;
3635
+ if (unit === 's') return value * 1000;
3636
+ if (unit === 'm') return value * 60000;
3637
+ return 0;
3638
+ }
3639
+
3640
+ // Helper to format duration
3641
+ function formatDuration(ms) {
3642
+ if (ms < 1000) return Math.round(ms) + 'ms';
3643
+ if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
3644
+ return (ms / 60000).toFixed(2) + 'm';
3645
+ }
3646
+ `
3647
+ }
3648
+ }