codeceptjs 4.0.0-beta.4 → 4.0.0-beta.5

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 (150) hide show
  1. package/README.md +134 -119
  2. package/bin/codecept.js +12 -2
  3. package/bin/test-server.js +53 -0
  4. package/docs/webapi/clearCookie.mustache +1 -1
  5. package/lib/actor.js +66 -102
  6. package/lib/ai.js +130 -121
  7. package/lib/assert/empty.js +3 -5
  8. package/lib/assert/equal.js +4 -7
  9. package/lib/assert/include.js +4 -6
  10. package/lib/assert/throws.js +2 -4
  11. package/lib/assert/truth.js +2 -2
  12. package/lib/codecept.js +139 -87
  13. package/lib/command/check.js +201 -0
  14. package/lib/command/configMigrate.js +2 -4
  15. package/lib/command/definitions.js +8 -26
  16. package/lib/command/generate.js +10 -14
  17. package/lib/command/gherkin/snippets.js +75 -73
  18. package/lib/command/gherkin/steps.js +1 -1
  19. package/lib/command/info.js +42 -8
  20. package/lib/command/init.js +13 -12
  21. package/lib/command/interactive.js +10 -2
  22. package/lib/command/list.js +1 -1
  23. package/lib/command/run-multiple/chunk.js +48 -45
  24. package/lib/command/run-multiple.js +12 -35
  25. package/lib/command/run-workers.js +21 -58
  26. package/lib/command/utils.js +5 -6
  27. package/lib/command/workers/runTests.js +262 -220
  28. package/lib/container.js +386 -238
  29. package/lib/data/context.js +10 -13
  30. package/lib/data/dataScenarioConfig.js +8 -8
  31. package/lib/data/dataTableArgument.js +6 -6
  32. package/lib/data/table.js +5 -11
  33. package/lib/effects.js +223 -0
  34. package/lib/element/WebElement.js +327 -0
  35. package/lib/els.js +158 -0
  36. package/lib/event.js +21 -17
  37. package/lib/heal.js +88 -80
  38. package/lib/helper/AI.js +2 -1
  39. package/lib/helper/ApiDataFactory.js +3 -6
  40. package/lib/helper/Appium.js +47 -51
  41. package/lib/helper/FileSystem.js +3 -3
  42. package/lib/helper/GraphQLDataFactory.js +3 -3
  43. package/lib/helper/JSONResponse.js +75 -37
  44. package/lib/helper/Mochawesome.js +31 -9
  45. package/lib/helper/Nightmare.js +35 -53
  46. package/lib/helper/Playwright.js +262 -267
  47. package/lib/helper/Protractor.js +54 -77
  48. package/lib/helper/Puppeteer.js +246 -260
  49. package/lib/helper/REST.js +5 -17
  50. package/lib/helper/TestCafe.js +21 -44
  51. package/lib/helper/WebDriver.js +151 -170
  52. package/lib/helper/extras/Popup.js +22 -22
  53. package/lib/helper/testcafe/testcafe-utils.js +26 -27
  54. package/lib/listener/emptyRun.js +55 -0
  55. package/lib/listener/exit.js +7 -10
  56. package/lib/listener/{retry.js → globalRetry.js} +5 -5
  57. package/lib/listener/globalTimeout.js +165 -0
  58. package/lib/listener/helpers.js +15 -15
  59. package/lib/listener/mocha.js +1 -1
  60. package/lib/listener/result.js +12 -0
  61. package/lib/listener/retryEnhancer.js +85 -0
  62. package/lib/listener/steps.js +32 -18
  63. package/lib/listener/store.js +20 -0
  64. package/lib/mocha/asyncWrapper.js +231 -0
  65. package/lib/{interfaces → mocha}/bdd.js +3 -3
  66. package/lib/mocha/cli.js +308 -0
  67. package/lib/mocha/factory.js +104 -0
  68. package/lib/{interfaces → mocha}/featureConfig.js +32 -12
  69. package/lib/{interfaces → mocha}/gherkin.js +26 -28
  70. package/lib/mocha/hooks.js +112 -0
  71. package/lib/mocha/index.js +12 -0
  72. package/lib/mocha/inject.js +29 -0
  73. package/lib/{interfaces → mocha}/scenarioConfig.js +31 -7
  74. package/lib/mocha/suite.js +82 -0
  75. package/lib/mocha/test.js +181 -0
  76. package/lib/mocha/types.d.ts +42 -0
  77. package/lib/mocha/ui.js +232 -0
  78. package/lib/output.js +82 -62
  79. package/lib/pause.js +160 -138
  80. package/lib/plugin/analyze.js +396 -0
  81. package/lib/plugin/auth.js +435 -0
  82. package/lib/plugin/autoDelay.js +8 -8
  83. package/lib/plugin/autoLogin.js +3 -338
  84. package/lib/plugin/commentStep.js +6 -1
  85. package/lib/plugin/coverage.js +10 -19
  86. package/lib/plugin/customLocator.js +3 -3
  87. package/lib/plugin/customReporter.js +52 -0
  88. package/lib/plugin/eachElement.js +1 -1
  89. package/lib/plugin/fakerTransform.js +1 -1
  90. package/lib/plugin/heal.js +36 -9
  91. package/lib/plugin/htmlReporter.js +1947 -0
  92. package/lib/plugin/pageInfo.js +140 -0
  93. package/lib/plugin/retryFailedStep.js +17 -18
  94. package/lib/plugin/retryTo.js +2 -113
  95. package/lib/plugin/screenshotOnFail.js +17 -58
  96. package/lib/plugin/selenoid.js +15 -35
  97. package/lib/plugin/standardActingHelpers.js +4 -1
  98. package/lib/plugin/stepByStepReport.js +56 -17
  99. package/lib/plugin/stepTimeout.js +5 -12
  100. package/lib/plugin/subtitles.js +4 -4
  101. package/lib/plugin/tryTo.js +3 -102
  102. package/lib/plugin/wdio.js +8 -10
  103. package/lib/recorder.js +155 -124
  104. package/lib/rerun.js +43 -42
  105. package/lib/result.js +161 -0
  106. package/lib/secret.js +1 -1
  107. package/lib/step/base.js +239 -0
  108. package/lib/step/comment.js +10 -0
  109. package/lib/step/config.js +50 -0
  110. package/lib/step/func.js +46 -0
  111. package/lib/step/helper.js +50 -0
  112. package/lib/step/meta.js +99 -0
  113. package/lib/step/record.js +74 -0
  114. package/lib/step/retry.js +11 -0
  115. package/lib/step/section.js +55 -0
  116. package/lib/step.js +21 -332
  117. package/lib/steps.js +50 -0
  118. package/lib/store.js +37 -5
  119. package/lib/template/heal.js +2 -11
  120. package/lib/test-server.js +323 -0
  121. package/lib/timeout.js +66 -0
  122. package/lib/utils.js +351 -218
  123. package/lib/within.js +75 -55
  124. package/lib/workerStorage.js +2 -1
  125. package/lib/workers.js +386 -276
  126. package/package.json +76 -70
  127. package/translations/de-DE.js +4 -3
  128. package/translations/fr-FR.js +4 -3
  129. package/translations/index.js +1 -0
  130. package/translations/it-IT.js +4 -3
  131. package/translations/ja-JP.js +4 -3
  132. package/translations/nl-NL.js +76 -0
  133. package/translations/pl-PL.js +4 -3
  134. package/translations/pt-BR.js +4 -3
  135. package/translations/ru-RU.js +4 -3
  136. package/translations/utils.js +9 -0
  137. package/translations/zh-CN.js +4 -3
  138. package/translations/zh-TW.js +4 -3
  139. package/typings/index.d.ts +188 -186
  140. package/typings/promiseBasedTypes.d.ts +18 -705
  141. package/typings/types.d.ts +301 -804
  142. package/lib/cli.js +0 -256
  143. package/lib/helper/ExpectHelper.js +0 -391
  144. package/lib/helper/SoftExpectHelper.js +0 -381
  145. package/lib/listener/artifacts.js +0 -19
  146. package/lib/listener/timeout.js +0 -109
  147. package/lib/mochaFactory.js +0 -113
  148. package/lib/plugin/debugErrors.js +0 -67
  149. package/lib/scenario.js +0 -224
  150. package/lib/ui.js +0 -236
@@ -0,0 +1,1947 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const mkdirp = require('mkdirp')
4
+ const crypto = require('crypto')
5
+ const { template } = require('../utils')
6
+ const { getMachineInfo } = require('../command/info')
7
+
8
+ const event = require('../event')
9
+ const output = require('../output')
10
+ const Codecept = require('../codecept')
11
+
12
+ const defaultConfig = {
13
+ output: global.output_dir || './output',
14
+ reportFileName: 'report.html',
15
+ includeArtifacts: true,
16
+ showSteps: true,
17
+ showSkipped: true,
18
+ showMetadata: true,
19
+ showTags: true,
20
+ showRetries: true,
21
+ exportStats: false,
22
+ exportStatsPath: './stats.json',
23
+ keepHistory: false,
24
+ historyPath: './test-history.json',
25
+ maxHistoryEntries: 50,
26
+ }
27
+
28
+ /**
29
+ * HTML Reporter Plugin for CodeceptJS
30
+ *
31
+ * Generates comprehensive HTML reports showing:
32
+ * - Test statistics
33
+ * - Feature/Scenario details
34
+ * - Individual step results
35
+ * - Test artifacts (screenshots, etc.)
36
+ *
37
+ * ## Configuration
38
+ *
39
+ * ```js
40
+ * "plugins": {
41
+ * "htmlReporter": {
42
+ * "enabled": true,
43
+ * "output": "./output",
44
+ * "reportFileName": "report.html",
45
+ * "includeArtifacts": true,
46
+ * "showSteps": true,
47
+ * "showSkipped": true,
48
+ * "showMetadata": true,
49
+ * "showTags": true,
50
+ * "showRetries": true,
51
+ * "exportStats": false,
52
+ * "exportStatsPath": "./stats.json",
53
+ * "keepHistory": false,
54
+ * "historyPath": "./test-history.json",
55
+ * "maxHistoryEntries": 50
56
+ * }
57
+ * }
58
+ * ```
59
+ */
60
+ module.exports = function (config) {
61
+ const options = { ...defaultConfig, ...config }
62
+ let reportData = {
63
+ stats: {},
64
+ tests: [],
65
+ failures: [],
66
+ hooks: [],
67
+ startTime: null,
68
+ endTime: null,
69
+ retries: [],
70
+ config: options,
71
+ }
72
+ let currentTestSteps = []
73
+ let currentTestHooks = []
74
+ let currentBddSteps = [] // Track BDD/Gherkin steps
75
+ let testRetryAttempts = new Map() // Track retry attempts per test
76
+ let currentSuite = null // Track current suite for BDD detection
77
+
78
+ // Initialize report directory
79
+ const reportDir = options.output ? path.resolve(global.codecept_dir, options.output) : path.resolve(global.output_dir || './output')
80
+ mkdirp.sync(reportDir)
81
+
82
+ // Track overall test execution
83
+ event.dispatcher.on(event.all.before, () => {
84
+ reportData.startTime = new Date()
85
+ output.plugin('htmlReporter', 'Starting HTML report generation...')
86
+ })
87
+
88
+ // Track test start to initialize steps and hooks collection
89
+ event.dispatcher.on(event.test.before, test => {
90
+ currentTestSteps = []
91
+ currentTestHooks = []
92
+ currentBddSteps = []
93
+
94
+ // Track current suite for BDD detection
95
+ currentSuite = test.parent
96
+
97
+ // Track retry attempts
98
+ if (test.retriedTest && test.retriedTest()) {
99
+ const originalTest = test.retriedTest()
100
+ const testId = generateTestId(originalTest)
101
+ if (!testRetryAttempts.has(testId)) {
102
+ testRetryAttempts.set(testId, 0)
103
+ }
104
+ testRetryAttempts.set(testId, testRetryAttempts.get(testId) + 1)
105
+ }
106
+ })
107
+
108
+ // Collect step information
109
+ event.dispatcher.on(event.step.started, step => {
110
+ step.htmlReporterStartTime = Date.now()
111
+ })
112
+
113
+ event.dispatcher.on(event.step.finished, step => {
114
+ if (step.htmlReporterStartTime) {
115
+ step.duration = Date.now() - step.htmlReporterStartTime
116
+ }
117
+ currentTestSteps.push({
118
+ name: step.name,
119
+ actor: step.actor,
120
+ args: step.args || [],
121
+ status: step.failed ? 'failed' : 'success',
122
+ duration: step.duration || 0,
123
+ })
124
+ })
125
+
126
+ // Collect hook information
127
+ event.dispatcher.on(event.hook.started, hook => {
128
+ hook.htmlReporterStartTime = Date.now()
129
+ })
130
+
131
+ event.dispatcher.on(event.hook.finished, hook => {
132
+ if (hook.htmlReporterStartTime) {
133
+ hook.duration = Date.now() - hook.htmlReporterStartTime
134
+ }
135
+ const hookInfo = {
136
+ title: hook.title,
137
+ type: hook.type || 'unknown', // before, after, beforeSuite, afterSuite
138
+ status: hook.err ? 'failed' : 'passed',
139
+ duration: hook.duration || 0,
140
+ error: hook.err ? hook.err.message : null,
141
+ }
142
+ currentTestHooks.push(hookInfo)
143
+ reportData.hooks.push(hookInfo)
144
+ })
145
+
146
+ // Collect BDD/Gherkin step information
147
+ event.dispatcher.on(event.bddStep.started, step => {
148
+ step.htmlReporterStartTime = Date.now()
149
+ })
150
+
151
+ event.dispatcher.on(event.bddStep.finished, step => {
152
+ if (step.htmlReporterStartTime) {
153
+ step.duration = Date.now() - step.htmlReporterStartTime
154
+ }
155
+ currentBddSteps.push({
156
+ keyword: step.actor || 'Given',
157
+ text: step.name,
158
+ status: step.failed ? 'failed' : 'success',
159
+ duration: step.duration || 0,
160
+ comment: step.comment,
161
+ })
162
+ })
163
+
164
+ // Collect test results
165
+ event.dispatcher.on(event.test.finished, test => {
166
+ const testId = generateTestId(test)
167
+ const retryAttempts = testRetryAttempts.get(testId) || 0
168
+
169
+ // Detect if this is a BDD/Gherkin test
170
+ const isBddTest = isBddGherkinTest(test, currentSuite)
171
+ const steps = isBddTest ? currentBddSteps : currentTestSteps
172
+ const featureInfo = isBddTest ? getBddFeatureInfo(test, currentSuite) : null
173
+
174
+ reportData.tests.push({
175
+ ...test,
176
+ id: testId,
177
+ duration: test.duration || 0,
178
+ steps: [...steps], // Copy the steps (BDD or regular)
179
+ hooks: [...currentTestHooks], // Copy the hooks
180
+ artifacts: test.artifacts || [],
181
+ tags: test.tags || [],
182
+ meta: test.meta || {},
183
+ opts: test.opts || {},
184
+ notes: test.notes || [],
185
+ retryAttempts: retryAttempts,
186
+ uid: test.uid,
187
+ isBdd: isBddTest,
188
+ feature: featureInfo,
189
+ })
190
+
191
+ // If this was a retry, track the retry information
192
+ if (retryAttempts > 0) {
193
+ reportData.retries.push({
194
+ testId: testId,
195
+ testTitle: test.title,
196
+ attempts: retryAttempts,
197
+ finalState: test.state,
198
+ duration: test.duration || 0,
199
+ })
200
+ }
201
+ })
202
+
203
+ // Generate final report
204
+ event.dispatcher.on(event.all.result, result => {
205
+ reportData.endTime = new Date()
206
+ reportData.stats = result.stats
207
+ reportData.failures = result.failures || []
208
+ reportData.duration = reportData.endTime - reportData.startTime
209
+
210
+ generateHtmlReport(reportData, options)
211
+
212
+ // Export stats if configured
213
+ if (options.exportStats) {
214
+ exportTestStats(reportData, options)
215
+ }
216
+
217
+ // Save history if configured
218
+ if (options.keepHistory) {
219
+ saveTestHistory(reportData, options)
220
+ }
221
+ })
222
+
223
+ function generateTestId(test) {
224
+ return crypto
225
+ .createHash('sha256')
226
+ .update(`${test.parent?.title || 'unknown'}_${test.title}`)
227
+ .digest('hex')
228
+ .substring(0, 8)
229
+ }
230
+
231
+ function isBddGherkinTest(test, suite) {
232
+ // Check if the suite has BDD/Gherkin properties
233
+ return !!(suite && (suite.feature || suite.file?.endsWith('.feature')))
234
+ }
235
+
236
+ function getBddFeatureInfo(test, suite) {
237
+ if (!suite) return null
238
+
239
+ return {
240
+ name: suite.feature?.name || suite.title,
241
+ description: suite.feature?.description || suite.comment || '',
242
+ language: suite.feature?.language || 'en',
243
+ tags: suite.tags || [],
244
+ file: suite.file || '',
245
+ }
246
+ }
247
+
248
+ function exportTestStats(data, config) {
249
+ const statsPath = path.resolve(reportDir, config.exportStatsPath)
250
+
251
+ const exportData = {
252
+ timestamp: data.endTime.toISOString(),
253
+ duration: data.duration,
254
+ stats: data.stats,
255
+ retries: data.retries,
256
+ testCount: data.tests.length,
257
+ passedTests: data.tests.filter(t => t.state === 'passed').length,
258
+ failedTests: data.tests.filter(t => t.state === 'failed').length,
259
+ pendingTests: data.tests.filter(t => t.state === 'pending').length,
260
+ tests: data.tests.map(test => ({
261
+ id: test.id,
262
+ title: test.title,
263
+ feature: test.parent?.title || 'Unknown',
264
+ state: test.state,
265
+ duration: test.duration,
266
+ tags: test.tags,
267
+ meta: test.meta,
268
+ retryAttempts: test.retryAttempts,
269
+ uid: test.uid,
270
+ })),
271
+ }
272
+
273
+ try {
274
+ fs.writeFileSync(statsPath, JSON.stringify(exportData, null, 2))
275
+ output.print(`Test stats exported to: ${statsPath}`)
276
+ } catch (error) {
277
+ output.print(`Failed to export test stats: ${error.message}`)
278
+ }
279
+ }
280
+
281
+ function saveTestHistory(data, config) {
282
+ const historyPath = path.resolve(reportDir, config.historyPath)
283
+ let history = []
284
+
285
+ // Load existing history
286
+ try {
287
+ if (fs.existsSync(historyPath)) {
288
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf8'))
289
+ }
290
+ } catch (error) {
291
+ output.print(`Failed to load existing history: ${error.message}`)
292
+ }
293
+
294
+ // Add current run to history
295
+ history.unshift({
296
+ timestamp: data.endTime.toISOString(),
297
+ duration: data.duration,
298
+ stats: data.stats,
299
+ retries: data.retries.length,
300
+ testCount: data.tests.length,
301
+ })
302
+
303
+ // Limit history entries
304
+ if (history.length > config.maxHistoryEntries) {
305
+ history = history.slice(0, config.maxHistoryEntries)
306
+ }
307
+
308
+ try {
309
+ fs.writeFileSync(historyPath, JSON.stringify(history, null, 2))
310
+ output.print(`Test history saved to: ${historyPath}`)
311
+ } catch (error) {
312
+ output.print(`Failed to save test history: ${error.message}`)
313
+ }
314
+ }
315
+
316
+ async function generateHtmlReport(data, config) {
317
+ const reportPath = path.join(reportDir, config.reportFileName)
318
+
319
+ // Load history if available
320
+ let history = []
321
+ if (config.keepHistory) {
322
+ const historyPath = path.resolve(reportDir, config.historyPath)
323
+ try {
324
+ if (fs.existsSync(historyPath)) {
325
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf8')).slice(0, 10) // Last 10 runs for chart
326
+ }
327
+ } catch (error) {
328
+ output.print(`Failed to load history for report: ${error.message}`)
329
+ }
330
+ }
331
+
332
+ // Get system information
333
+ const systemInfo = await getMachineInfo()
334
+
335
+ const html = template(getHtmlTemplate(), {
336
+ title: `CodeceptJS Test Report v${Codecept.version()}`,
337
+ timestamp: data.endTime.toISOString(),
338
+ duration: formatDuration(data.duration),
339
+ stats: JSON.stringify(data.stats),
340
+ history: JSON.stringify(history),
341
+ statsHtml: generateStatsHtml(data.stats),
342
+ testsHtml: generateTestsHtml(data.tests, config),
343
+ failuresHtml: generateFailuresHtml(data.failures),
344
+ retriesHtml: config.showRetries ? generateRetriesHtml(data.retries) : '',
345
+ cssStyles: getCssStyles(),
346
+ jsScripts: getJsScripts(),
347
+ showRetries: config.showRetries ? 'block' : 'none',
348
+ showHistory: config.keepHistory && history.length > 0 ? 'block' : 'none',
349
+ failuresDisplay: data.failures && data.failures.length > 0 ? 'block' : 'none',
350
+ codeceptVersion: Codecept.version(),
351
+ systemInfoHtml: generateSystemInfoHtml(systemInfo),
352
+ })
353
+
354
+ fs.writeFileSync(reportPath, html)
355
+ output.print(`HTML Report saved to: ${reportPath}`)
356
+ }
357
+
358
+ function generateStatsHtml(stats) {
359
+ const passed = stats.passes || 0
360
+ const failed = stats.failures || 0
361
+ const pending = stats.pending || 0
362
+ const total = stats.tests || 0
363
+
364
+ return `
365
+ <div class="stats-cards">
366
+ <div class="stat-card total">
367
+ <h3>Total</h3>
368
+ <span class="stat-number">${total}</span>
369
+ </div>
370
+ <div class="stat-card passed">
371
+ <h3>Passed</h3>
372
+ <span class="stat-number">${passed}</span>
373
+ </div>
374
+ <div class="stat-card failed">
375
+ <h3>Failed</h3>
376
+ <span class="stat-number">${failed}</span>
377
+ </div>
378
+ <div class="stat-card pending">
379
+ <h3>Pending</h3>
380
+ <span class="stat-number">${pending}</span>
381
+ </div>
382
+ </div>
383
+ <div class="pie-chart-container">
384
+ <canvas id="statsChart" width="300" height="300"></canvas>
385
+ <script>
386
+ // Pie chart data will be rendered by JavaScript
387
+ window.chartData = {
388
+ passed: ${passed},
389
+ failed: ${failed},
390
+ pending: ${pending}
391
+ };
392
+ </script>
393
+ </div>
394
+ `
395
+ }
396
+
397
+ function generateTestsHtml(tests, config) {
398
+ if (!tests || tests.length === 0) {
399
+ return '<p>No tests found.</p>'
400
+ }
401
+
402
+ return tests
403
+ .map(test => {
404
+ const statusClass = test.state || 'unknown'
405
+ const feature = test.isBdd && test.feature ? test.feature.name : test.parent?.title || 'Unknown Feature'
406
+ const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : ''
407
+ const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : ''
408
+ const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : ''
409
+ const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts) : ''
410
+ const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : ''
411
+ const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : ''
412
+ const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts) : ''
413
+ const notes = test.notes && test.notes.length > 0 ? generateNotesHtml(test.notes) : ''
414
+
415
+ return `
416
+ <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'}">
417
+ <div class="test-header" onclick="toggleTestDetails('test-${test.id}')">
418
+ <span class="test-status ${statusClass}">●</span>
419
+ <div class="test-info">
420
+ <h3 class="test-title">${test.isBdd ? `Scenario: ${test.title}` : test.title}</h3>
421
+ <div class="test-meta-line">
422
+ <span class="test-feature">${test.isBdd ? 'Feature: ' : ''}${feature}</span>
423
+ ${test.uid ? `<span class="test-uid">${test.uid}</span>` : ''}
424
+ <span class="test-duration">${formatDuration(test.duration)}</span>
425
+ ${test.retryAttempts > 0 ? `<span class="retry-badge">${test.retryAttempts} retries</span>` : ''}
426
+ ${test.isBdd ? '<span class="bdd-badge">Gherkin</span>' : ''}
427
+ </div>
428
+ </div>
429
+ </div>
430
+ <div class="test-details" id="details-test-${test.id}">
431
+ ${test.err ? `<div class="error-message"><pre>${escapeHtml(test.err.message || '').replace(/\x1b\[[0-9;]*m/g, '')}</pre></div>` : ''}
432
+ ${featureDetails}
433
+ ${tags}
434
+ ${metadata}
435
+ ${retries}
436
+ ${notes}
437
+ ${hooks}
438
+ ${steps}
439
+ ${artifacts}
440
+ </div>
441
+ </div>
442
+ `
443
+ })
444
+ .join('')
445
+ }
446
+
447
+ function generateStepsHtml(steps) {
448
+ if (!steps || steps.length === 0) return ''
449
+
450
+ const stepsHtml = steps
451
+ .map(step => {
452
+ const statusClass = step.status || 'unknown'
453
+ const args = step.args ? step.args.map(arg => JSON.stringify(arg)).join(', ') : ''
454
+ const stepName = step.name || 'unknown step'
455
+ const actor = step.actor || 'I'
456
+
457
+ return `
458
+ <div class="step-item ${statusClass}">
459
+ <span class="step-status ${statusClass}">●</span>
460
+ <span class="step-title">${actor}.${stepName}(${args})</span>
461
+ <span class="step-duration">${formatDuration(step.duration)}</span>
462
+ </div>
463
+ `
464
+ })
465
+ .join('')
466
+
467
+ return `
468
+ <div class="steps-section">
469
+ <h4>Steps:</h4>
470
+ <div class="steps-list">${stepsHtml}</div>
471
+ </div>
472
+ `
473
+ }
474
+
475
+ function generateBddStepsHtml(steps) {
476
+ if (!steps || steps.length === 0) return ''
477
+
478
+ const stepsHtml = steps
479
+ .map(step => {
480
+ const statusClass = step.status || 'unknown'
481
+ const keyword = step.keyword || 'Given'
482
+ const text = step.text || ''
483
+ const comment = step.comment ? `<div class="step-comment">${escapeHtml(step.comment)}</div>` : ''
484
+
485
+ return `
486
+ <div class="bdd-step-item ${statusClass}">
487
+ <span class="step-status ${statusClass}">●</span>
488
+ <span class="bdd-keyword">${keyword}</span>
489
+ <span class="bdd-step-text">${escapeHtml(text)}</span>
490
+ <span class="step-duration">${formatDuration(step.duration)}</span>
491
+ ${comment}
492
+ </div>
493
+ `
494
+ })
495
+ .join('')
496
+
497
+ return `
498
+ <div class="bdd-steps-section">
499
+ <h4>Scenario Steps:</h4>
500
+ <div class="bdd-steps-list">${stepsHtml}</div>
501
+ </div>
502
+ `
503
+ }
504
+
505
+ function generateBddFeatureHtml(feature) {
506
+ if (!feature) return ''
507
+
508
+ const description = feature.description ? `<div class="feature-description">${escapeHtml(feature.description)}</div>` : ''
509
+ 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>` : ''
510
+
511
+ return `
512
+ <div class="bdd-feature-section">
513
+ <h4>Feature Information:</h4>
514
+ <div class="feature-info">
515
+ <div class="feature-name">Feature: ${escapeHtml(feature.name)}</div>
516
+ ${description}
517
+ ${featureTags}
518
+ ${feature.file ? `<div class="feature-file">File: ${escapeHtml(feature.file)}</div>` : ''}
519
+ </div>
520
+ </div>
521
+ `
522
+ }
523
+
524
+ function generateHooksHtml(hooks) {
525
+ if (!hooks || hooks.length === 0) return ''
526
+
527
+ const hooksHtml = hooks
528
+ .map(hook => {
529
+ const statusClass = hook.status || 'unknown'
530
+ const hookType = hook.type || 'hook'
531
+ const hookTitle = hook.title || `${hookType} hook`
532
+
533
+ return `
534
+ <div class="hook-item ${statusClass}">
535
+ <span class="hook-status ${statusClass}">●</span>
536
+ <span class="hook-title">${hookType}: ${hookTitle}</span>
537
+ <span class="hook-duration">${formatDuration(hook.duration)}</span>
538
+ ${hook.error ? `<div class="hook-error">${escapeHtml(hook.error)}</div>` : ''}
539
+ </div>
540
+ `
541
+ })
542
+ .join('')
543
+
544
+ return `
545
+ <div class="hooks-section">
546
+ <h4>Hooks:</h4>
547
+ <div class="hooks-list">${hooksHtml}</div>
548
+ </div>
549
+ `
550
+ }
551
+
552
+ function generateMetadataHtml(meta, opts) {
553
+ const allMeta = { ...(opts || {}), ...(meta || {}) }
554
+ if (!allMeta || Object.keys(allMeta).length === 0) return ''
555
+
556
+ const metaHtml = Object.entries(allMeta)
557
+ .filter(([key, value]) => value !== undefined && value !== null)
558
+ .map(([key, value]) => {
559
+ const displayValue = typeof value === 'object' ? JSON.stringify(value) : value.toString()
560
+ return `<div class="meta-item"><span class="meta-key">${escapeHtml(key)}:</span> <span class="meta-value">${escapeHtml(displayValue)}</span></div>`
561
+ })
562
+ .join('')
563
+
564
+ return `
565
+ <div class="metadata-section">
566
+ <h4>Metadata:</h4>
567
+ <div class="metadata-list">${metaHtml}</div>
568
+ </div>
569
+ `
570
+ }
571
+
572
+ function generateTagsHtml(tags) {
573
+ if (!tags || tags.length === 0) return ''
574
+
575
+ const tagsHtml = tags.map(tag => `<span class="test-tag">${escapeHtml(tag)}</span>`).join('')
576
+
577
+ return `
578
+ <div class="tags-section">
579
+ <h4>Tags:</h4>
580
+ <div class="tags-list">${tagsHtml}</div>
581
+ </div>
582
+ `
583
+ }
584
+
585
+ function generateNotesHtml(notes) {
586
+ if (!notes || notes.length === 0) return ''
587
+
588
+ 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('')
589
+
590
+ return `
591
+ <div class="notes-section">
592
+ <h4>Notes:</h4>
593
+ <div class="notes-list">${notesHtml}</div>
594
+ </div>
595
+ `
596
+ }
597
+
598
+ function generateTestRetryHtml(retryAttempts) {
599
+ return `
600
+ <div class="retry-section">
601
+ <h4>Retry Information:</h4>
602
+ <div class="retry-info">
603
+ <span class="retry-count">Total retry attempts: <strong>${retryAttempts}</strong></span>
604
+ </div>
605
+ </div>
606
+ `
607
+ }
608
+
609
+ function generateArtifactsHtml(artifacts) {
610
+ if (!artifacts || artifacts.length === 0) return ''
611
+
612
+ const artifactsHtml = artifacts
613
+ .map(artifact => {
614
+ if (typeof artifact === 'string' && artifact.match(/\.(png|jpg|jpeg|gif)$/i)) {
615
+ const relativePath = path.relative(reportDir, artifact)
616
+ return `<img src="${relativePath}" alt="Screenshot" class="artifact-image" onclick="openImageModal(this.src)"/>`
617
+ }
618
+ return `<div class="artifact-item">${escapeHtml(artifact.toString())}</div>`
619
+ })
620
+ .join('')
621
+
622
+ return `
623
+ <div class="artifacts-section">
624
+ <h4>Artifacts:</h4>
625
+ <div class="artifacts-list">${artifactsHtml}</div>
626
+ </div>
627
+ `
628
+ }
629
+
630
+ function generateFailuresHtml(failures) {
631
+ if (!failures || failures.length === 0) {
632
+ return '<p>No failures.</p>'
633
+ }
634
+
635
+ return failures
636
+ .map((failure, index) => {
637
+ const failureText = failure.toString().replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI escape codes
638
+ return `
639
+ <div class="failure-item">
640
+ <h4>Failure ${index + 1}</h4>
641
+ <pre class="failure-details">${escapeHtml(failureText)}</pre>
642
+ </div>
643
+ `
644
+ })
645
+ .join('')
646
+ }
647
+
648
+ function generateRetriesHtml(retries) {
649
+ if (!retries || retries.length === 0) {
650
+ return '<p>No retried tests.</p>'
651
+ }
652
+
653
+ return retries
654
+ .map(
655
+ retry => `
656
+ <div class="retry-item">
657
+ <h4>${retry.testTitle}</h4>
658
+ <div class="retry-details">
659
+ <span>Attempts: <strong>${retry.attempts}</strong></span>
660
+ <span>Final State: <span class="status-badge ${retry.finalState}">${retry.finalState}</span></span>
661
+ <span>Duration: ${formatDuration(retry.duration)}</span>
662
+ </div>
663
+ </div>
664
+ `,
665
+ )
666
+ .join('')
667
+ }
668
+
669
+ function formatDuration(duration) {
670
+ if (!duration) return '0ms'
671
+ if (duration < 1000) return `${duration}ms`
672
+ return `${(duration / 1000).toFixed(2)}s`
673
+ }
674
+
675
+ function escapeHtml(unsafe) {
676
+ return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
677
+ }
678
+
679
+ function generateSystemInfoHtml(systemInfo) {
680
+ if (!systemInfo) return ''
681
+
682
+ const formatInfo = (key, value) => {
683
+ if (Array.isArray(value) && value.length > 1) {
684
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value[1])}</span></div>`
685
+ } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') {
686
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value)}</span></div>`
687
+ }
688
+ return ''
689
+ }
690
+
691
+ const infoItems = [
692
+ formatInfo('Node.js', systemInfo.nodeInfo),
693
+ formatInfo('OS', systemInfo.osInfo),
694
+ formatInfo('CPU', systemInfo.cpuInfo),
695
+ formatInfo('Chrome', systemInfo.chromeInfo),
696
+ formatInfo('Edge', systemInfo.edgeInfo),
697
+ formatInfo('Firefox', systemInfo.firefoxInfo),
698
+ formatInfo('Safari', systemInfo.safariInfo),
699
+ formatInfo('Playwright Browsers', systemInfo.playwrightBrowsers),
700
+ ]
701
+ .filter(item => item)
702
+ .join('')
703
+
704
+ if (!infoItems) return ''
705
+
706
+ return `
707
+ <section class="system-info-section">
708
+ <div class="system-info-header" onclick="toggleSystemInfo()">
709
+ <h3>Environment Information</h3>
710
+ <span class="toggle-icon">▼</span>
711
+ </div>
712
+ <div class="system-info-content" id="systemInfoContent">
713
+ <div class="system-info-grid">
714
+ ${infoItems}
715
+ </div>
716
+ </div>
717
+ </section>
718
+ `
719
+ }
720
+
721
+ function getHtmlTemplate() {
722
+ return `
723
+ <!DOCTYPE html>
724
+ <html lang="en">
725
+ <head>
726
+ <meta charset="UTF-8">
727
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
728
+ <title>{{title}}</title>
729
+ <style>{{cssStyles}}</style>
730
+ </head>
731
+ <body>
732
+ <header class="report-header">
733
+ <h1>{{title}}</h1>
734
+ <div class="report-meta">
735
+ <span>Generated: {{timestamp}}</span>
736
+ <span>Duration: {{duration}}</span>
737
+ </div>
738
+ </header>
739
+
740
+ <main class="report-content">
741
+ {{systemInfoHtml}}
742
+
743
+ <section class="stats-section">
744
+ <h2>Test Statistics</h2>
745
+ {{statsHtml}}
746
+ </section>
747
+
748
+ <section class="history-section" style="display: {{showHistory}};">
749
+ <h2>Test History</h2>
750
+ <div class="history-chart-container">
751
+ <canvas id="historyChart" width="800" height="300"></canvas>
752
+ </div>
753
+ </section>
754
+
755
+ <section class="filters-section">
756
+ <h2>Filters</h2>
757
+ <div class="filter-controls">
758
+ <div class="filter-group">
759
+ <label>Status:</label>
760
+ <select id="statusFilter" multiple>
761
+ <option value="passed">Passed</option>
762
+ <option value="failed">Failed</option>
763
+ <option value="pending">Pending</option>
764
+ <option value="skipped">Skipped</option>
765
+ </select>
766
+ </div>
767
+ <div class="filter-group">
768
+ <label>Feature:</label>
769
+ <input type="text" id="featureFilter" placeholder="Filter by feature...">
770
+ </div>
771
+ <div class="filter-group">
772
+ <label>Tags:</label>
773
+ <input type="text" id="tagFilter" placeholder="Filter by tags...">
774
+ </div>
775
+ <div class="filter-group">
776
+ <label>Retries:</label>
777
+ <select id="retryFilter">
778
+ <option value="all">All</option>
779
+ <option value="retried">With Retries</option>
780
+ <option value="no-retries">No Retries</option>
781
+ </select>
782
+ </div>
783
+ <div class="filter-group">
784
+ <label>Test Type:</label>
785
+ <select id="typeFilter">
786
+ <option value="all">All</option>
787
+ <option value="bdd">BDD/Gherkin</option>
788
+ <option value="regular">Regular</option>
789
+ </select>
790
+ </div>
791
+ <button onclick="resetFilters()">Reset Filters</button>
792
+ </div>
793
+ </section>
794
+
795
+ <section class="tests-section">
796
+ <h2>Test Results</h2>
797
+ <div class="tests-container">
798
+ {{testsHtml}}
799
+ </div>
800
+ </section>
801
+
802
+ <section class="retries-section" style="display: {{showRetries}};">
803
+ <h2>Test Retries</h2>
804
+ <div class="retries-container">
805
+ {{retriesHtml}}
806
+ </div>
807
+ </section>
808
+
809
+ <section class="failures-section" style="display: {{failuresDisplay}};">
810
+ <h2>Failures</h2>
811
+ <div class="failures-container">
812
+ {{failuresHtml}}
813
+ </div>
814
+ </section>
815
+ </main>
816
+
817
+ <!-- Modal for images -->
818
+ <div id="imageModal" class="modal" onclick="closeImageModal()">
819
+ <img id="modalImage" src="" alt="Enlarged screenshot"/>
820
+ </div>
821
+
822
+ <script>
823
+ window.testData = {
824
+ stats: {{stats}},
825
+ history: {{history}}
826
+ };
827
+ </script>
828
+ <script>{{jsScripts}}</script>
829
+ </body>
830
+ </html>
831
+ `
832
+ }
833
+
834
+ function getCssStyles() {
835
+ return `
836
+ * {
837
+ margin: 0;
838
+ padding: 0;
839
+ box-sizing: border-box;
840
+ }
841
+
842
+ body {
843
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
844
+ line-height: 1.6;
845
+ color: #333;
846
+ background-color: #f5f5f5;
847
+ }
848
+
849
+ .report-header {
850
+ background: #2c3e50;
851
+ color: white;
852
+ padding: 2rem 1rem;
853
+ text-align: center;
854
+ }
855
+
856
+ .report-header h1 {
857
+ margin-bottom: 0.5rem;
858
+ font-size: 2.5rem;
859
+ }
860
+
861
+ .report-meta {
862
+ font-size: 0.9rem;
863
+ opacity: 0.8;
864
+ }
865
+
866
+ .report-meta span {
867
+ margin: 0 1rem;
868
+ }
869
+
870
+ .report-content {
871
+ max-width: 1200px;
872
+ margin: 2rem auto;
873
+ padding: 0 1rem;
874
+ }
875
+
876
+ .stats-section, .tests-section, .failures-section, .retries-section, .filters-section, .history-section, .system-info-section {
877
+ background: white;
878
+ margin-bottom: 2rem;
879
+ border-radius: 8px;
880
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
881
+ overflow: hidden;
882
+ }
883
+
884
+ .stats-section h2, .tests-section h2, .failures-section h2, .retries-section h2, .filters-section h2, .history-section h2 {
885
+ background: #34495e;
886
+ color: white;
887
+ padding: 1rem;
888
+ margin: 0;
889
+ }
890
+
891
+ .stats-cards {
892
+ display: flex;
893
+ flex-wrap: wrap;
894
+ gap: 1rem;
895
+ padding: 1rem;
896
+ }
897
+
898
+ .stat-card {
899
+ flex: 1;
900
+ min-width: 150px;
901
+ padding: 1rem;
902
+ text-align: center;
903
+ border-radius: 4px;
904
+ color: white;
905
+ }
906
+
907
+ .stat-card.total { background: #3498db; }
908
+ .stat-card.passed { background: #27ae60; }
909
+ .stat-card.failed { background: #e74c3c; }
910
+ .stat-card.pending { background: #f39c12; }
911
+
912
+ .stat-card h3 {
913
+ font-size: 0.9rem;
914
+ margin-bottom: 0.5rem;
915
+ }
916
+
917
+ .stat-number {
918
+ font-size: 2rem;
919
+ font-weight: bold;
920
+ }
921
+
922
+ .pie-chart-container {
923
+ display: flex;
924
+ justify-content: center;
925
+ align-items: center;
926
+ padding: 2rem 1rem;
927
+ background: white;
928
+ margin: 1rem 0;
929
+ border-radius: 8px;
930
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
931
+ }
932
+
933
+ #statsChart {
934
+ max-width: 100%;
935
+ height: auto;
936
+ }
937
+
938
+ .test-item {
939
+ border-bottom: 1px solid #eee;
940
+ margin: 0;
941
+ }
942
+
943
+ .test-item:last-child {
944
+ border-bottom: none;
945
+ }
946
+
947
+ .test-header {
948
+ display: flex;
949
+ align-items: center;
950
+ padding: 1rem;
951
+ cursor: pointer;
952
+ transition: background-color 0.2s;
953
+ }
954
+
955
+ .test-header:hover {
956
+ background-color: #f8f9fa;
957
+ }
958
+
959
+ .test-info {
960
+ flex: 1;
961
+ display: flex;
962
+ flex-direction: column;
963
+ gap: 0.25rem;
964
+ }
965
+
966
+ .test-meta-line {
967
+ display: flex;
968
+ align-items: center;
969
+ gap: 0.5rem;
970
+ font-size: 0.9rem;
971
+ }
972
+
973
+ .test-status {
974
+ font-size: 1.2rem;
975
+ margin-right: 0.5rem;
976
+ }
977
+
978
+ .test-status.passed { color: #27ae60; }
979
+ .test-status.failed { color: #e74c3c; }
980
+ .test-status.pending { color: #f39c12; }
981
+ .test-status.skipped { color: #95a5a6; }
982
+
983
+ .test-title {
984
+ font-size: 1.1rem;
985
+ font-weight: 500;
986
+ margin: 0;
987
+ }
988
+
989
+ .test-feature {
990
+ background: #ecf0f1;
991
+ padding: 0.25rem 0.5rem;
992
+ border-radius: 4px;
993
+ font-size: 0.8rem;
994
+ color: #34495e;
995
+ }
996
+
997
+ .test-uid {
998
+ background: #e8f4fd;
999
+ padding: 0.25rem 0.5rem;
1000
+ border-radius: 4px;
1001
+ font-size: 0.7rem;
1002
+ color: #2980b9;
1003
+ font-family: monospace;
1004
+ }
1005
+
1006
+ .retry-badge {
1007
+ background: #f39c12;
1008
+ color: white;
1009
+ padding: 0.25rem 0.5rem;
1010
+ border-radius: 4px;
1011
+ font-size: 0.7rem;
1012
+ font-weight: bold;
1013
+ }
1014
+
1015
+ .test-duration {
1016
+ font-size: 0.8rem;
1017
+ color: #7f8c8d;
1018
+ }
1019
+
1020
+ .test-details {
1021
+ display: none;
1022
+ padding: 1rem;
1023
+ background: #f8f9fa;
1024
+ border-top: 1px solid #e9ecef;
1025
+ }
1026
+
1027
+ .error-message {
1028
+ background: #fee;
1029
+ border: 1px solid #fcc;
1030
+ border-radius: 4px;
1031
+ padding: 1rem;
1032
+ margin-bottom: 1rem;
1033
+ }
1034
+
1035
+ .error-message pre {
1036
+ color: #c0392b;
1037
+ font-family: 'Courier New', monospace;
1038
+ font-size: 0.9rem;
1039
+ white-space: pre-wrap;
1040
+ word-wrap: break-word;
1041
+ }
1042
+
1043
+ .steps-section, .artifacts-section, .hooks-section {
1044
+ margin-top: 1rem;
1045
+ }
1046
+
1047
+ .steps-section h4, .artifacts-section h4, .hooks-section h4 {
1048
+ color: #34495e;
1049
+ margin-bottom: 0.5rem;
1050
+ font-size: 1rem;
1051
+ }
1052
+
1053
+ .hook-item {
1054
+ display: flex;
1055
+ align-items: center;
1056
+ padding: 0.5rem 0;
1057
+ border-bottom: 1px solid #ecf0f1;
1058
+ }
1059
+
1060
+ .hook-item:last-child {
1061
+ border-bottom: none;
1062
+ }
1063
+
1064
+ .hook-status {
1065
+ margin-right: 0.5rem;
1066
+ }
1067
+
1068
+ .hook-status.passed { color: #27ae60; }
1069
+ .hook-status.failed { color: #e74c3c; }
1070
+
1071
+ .hook-title {
1072
+ flex: 1;
1073
+ font-family: 'Courier New', monospace;
1074
+ font-size: 0.9rem;
1075
+ font-weight: bold;
1076
+ }
1077
+
1078
+ .hook-duration {
1079
+ font-size: 0.8rem;
1080
+ color: #7f8c8d;
1081
+ }
1082
+
1083
+ .hook-error {
1084
+ width: 100%;
1085
+ margin-top: 0.5rem;
1086
+ padding: 0.5rem;
1087
+ background: #fee;
1088
+ border: 1px solid #fcc;
1089
+ border-radius: 4px;
1090
+ color: #c0392b;
1091
+ font-size: 0.8rem;
1092
+ }
1093
+
1094
+ .step-item {
1095
+ display: flex;
1096
+ align-items: center;
1097
+ padding: 0.5rem 0;
1098
+ border-bottom: 1px solid #ecf0f1;
1099
+ }
1100
+
1101
+ .step-item:last-child {
1102
+ border-bottom: none;
1103
+ }
1104
+
1105
+ .step-status {
1106
+ margin-right: 0.5rem;
1107
+ }
1108
+
1109
+ .step-status.success { color: #27ae60; }
1110
+ .step-status.failed { color: #e74c3c; }
1111
+
1112
+ .step-title {
1113
+ flex: 1;
1114
+ font-family: 'Courier New', monospace;
1115
+ font-size: 0.9rem;
1116
+ }
1117
+
1118
+ .step-duration {
1119
+ font-size: 0.8rem;
1120
+ color: #7f8c8d;
1121
+ }
1122
+
1123
+ .artifacts-list {
1124
+ display: flex;
1125
+ flex-wrap: wrap;
1126
+ gap: 0.5rem;
1127
+ }
1128
+
1129
+ .artifact-image {
1130
+ max-width: 200px;
1131
+ max-height: 150px;
1132
+ border: 1px solid #ddd;
1133
+ border-radius: 4px;
1134
+ cursor: pointer;
1135
+ transition: transform 0.2s;
1136
+ }
1137
+
1138
+ .artifact-image:hover {
1139
+ transform: scale(1.05);
1140
+ }
1141
+
1142
+ .artifact-item {
1143
+ background: #ecf0f1;
1144
+ padding: 0.5rem;
1145
+ border-radius: 4px;
1146
+ font-size: 0.9rem;
1147
+ }
1148
+
1149
+ .modal {
1150
+ display: none;
1151
+ position: fixed;
1152
+ z-index: 1000;
1153
+ left: 0;
1154
+ top: 0;
1155
+ width: 100%;
1156
+ height: 100%;
1157
+ background-color: rgba(0,0,0,0.8);
1158
+ cursor: pointer;
1159
+ }
1160
+
1161
+ .modal img {
1162
+ position: absolute;
1163
+ top: 50%;
1164
+ left: 50%;
1165
+ transform: translate(-50%, -50%);
1166
+ max-width: 90%;
1167
+ max-height: 90%;
1168
+ border-radius: 4px;
1169
+ }
1170
+
1171
+ .failure-item {
1172
+ padding: 1rem;
1173
+ margin-bottom: 1rem;
1174
+ border: 1px solid #fcc;
1175
+ border-radius: 4px;
1176
+ background: #fee;
1177
+ }
1178
+
1179
+ .failure-item h4 {
1180
+ color: #c0392b;
1181
+ margin-bottom: 0.5rem;
1182
+ }
1183
+
1184
+ .failure-details {
1185
+ color: #333;
1186
+ font-family: 'Courier New', monospace;
1187
+ font-size: 0.9rem;
1188
+ white-space: pre-wrap;
1189
+ word-wrap: break-word;
1190
+ }
1191
+
1192
+ /* Filter Controls */
1193
+ .filter-controls {
1194
+ display: flex;
1195
+ flex-wrap: wrap;
1196
+ gap: 1rem;
1197
+ padding: 1rem;
1198
+ background: #f8f9fa;
1199
+ }
1200
+
1201
+ .filter-group {
1202
+ display: flex;
1203
+ flex-direction: column;
1204
+ gap: 0.25rem;
1205
+ }
1206
+
1207
+ .filter-group label {
1208
+ font-size: 0.9rem;
1209
+ font-weight: 500;
1210
+ color: #34495e;
1211
+ }
1212
+
1213
+ .filter-group input,
1214
+ .filter-group select {
1215
+ padding: 0.5rem;
1216
+ border: 1px solid #ddd;
1217
+ border-radius: 4px;
1218
+ font-size: 0.9rem;
1219
+ min-width: 150px;
1220
+ }
1221
+
1222
+ .filter-group select[multiple] {
1223
+ height: auto;
1224
+ min-height: 80px;
1225
+ }
1226
+
1227
+ .filter-controls button {
1228
+ padding: 0.5rem 1rem;
1229
+ background: #3498db;
1230
+ color: white;
1231
+ border: none;
1232
+ border-radius: 4px;
1233
+ cursor: pointer;
1234
+ font-size: 0.9rem;
1235
+ align-self: flex-end;
1236
+ }
1237
+
1238
+ .filter-controls button:hover {
1239
+ background: #2980b9;
1240
+ }
1241
+
1242
+ /* Test Tags */
1243
+ .tags-section, .metadata-section, .notes-section, .retry-section {
1244
+ margin-top: 1rem;
1245
+ }
1246
+
1247
+ .tags-list {
1248
+ display: flex;
1249
+ flex-wrap: wrap;
1250
+ gap: 0.5rem;
1251
+ }
1252
+
1253
+ .test-tag {
1254
+ background: #3498db;
1255
+ color: white;
1256
+ padding: 0.25rem 0.5rem;
1257
+ border-radius: 12px;
1258
+ font-size: 0.8rem;
1259
+ }
1260
+
1261
+ /* Metadata */
1262
+ .metadata-list {
1263
+ display: flex;
1264
+ flex-direction: column;
1265
+ gap: 0.5rem;
1266
+ }
1267
+
1268
+ .meta-item {
1269
+ padding: 0.5rem;
1270
+ background: #f8f9fa;
1271
+ border-radius: 4px;
1272
+ border-left: 3px solid #3498db;
1273
+ }
1274
+
1275
+ .meta-key {
1276
+ font-weight: bold;
1277
+ color: #2c3e50;
1278
+ }
1279
+
1280
+ .meta-value {
1281
+ color: #34495e;
1282
+ font-family: monospace;
1283
+ }
1284
+
1285
+ /* Notes */
1286
+ .notes-list {
1287
+ display: flex;
1288
+ flex-direction: column;
1289
+ gap: 0.5rem;
1290
+ }
1291
+
1292
+ .note-item {
1293
+ padding: 0.5rem;
1294
+ border-radius: 4px;
1295
+ border-left: 3px solid #95a5a6;
1296
+ }
1297
+
1298
+ .note-item.note-info {
1299
+ background: #e8f4fd;
1300
+ border-left-color: #3498db;
1301
+ }
1302
+
1303
+ .note-item.note-warning {
1304
+ background: #fef9e7;
1305
+ border-left-color: #f39c12;
1306
+ }
1307
+
1308
+ .note-item.note-error {
1309
+ background: #fee;
1310
+ border-left-color: #e74c3c;
1311
+ }
1312
+
1313
+ .note-item.note-retry {
1314
+ background: #f0f8e8;
1315
+ border-left-color: #27ae60;
1316
+ }
1317
+
1318
+ .note-type {
1319
+ font-weight: bold;
1320
+ text-transform: uppercase;
1321
+ font-size: 0.8rem;
1322
+ }
1323
+
1324
+ /* Retry Information */
1325
+ .retry-info {
1326
+ padding: 0.5rem;
1327
+ background: #fef9e7;
1328
+ border-radius: 4px;
1329
+ border-left: 3px solid #f39c12;
1330
+ }
1331
+
1332
+ .retry-count {
1333
+ color: #d68910;
1334
+ font-weight: 500;
1335
+ }
1336
+
1337
+ /* Retries Section */
1338
+ .retry-item {
1339
+ padding: 1rem;
1340
+ margin-bottom: 1rem;
1341
+ border: 1px solid #f39c12;
1342
+ border-radius: 4px;
1343
+ background: #fef9e7;
1344
+ }
1345
+
1346
+ .retry-item h4 {
1347
+ color: #d68910;
1348
+ margin-bottom: 0.5rem;
1349
+ }
1350
+
1351
+ .retry-details {
1352
+ display: flex;
1353
+ gap: 1rem;
1354
+ align-items: center;
1355
+ font-size: 0.9rem;
1356
+ }
1357
+
1358
+ .status-badge {
1359
+ padding: 0.25rem 0.5rem;
1360
+ border-radius: 4px;
1361
+ font-size: 0.8rem;
1362
+ font-weight: bold;
1363
+ text-transform: uppercase;
1364
+ }
1365
+
1366
+ .status-badge.passed {
1367
+ background: #27ae60;
1368
+ color: white;
1369
+ }
1370
+
1371
+ .status-badge.failed {
1372
+ background: #e74c3c;
1373
+ color: white;
1374
+ }
1375
+
1376
+ .status-badge.pending {
1377
+ background: #f39c12;
1378
+ color: white;
1379
+ }
1380
+
1381
+ /* History Chart */
1382
+ .history-chart-container {
1383
+ padding: 2rem 1rem;
1384
+ display: flex;
1385
+ justify-content: center;
1386
+ }
1387
+
1388
+ #historyChart {
1389
+ max-width: 100%;
1390
+ height: auto;
1391
+ }
1392
+
1393
+ /* Hidden items for filtering */
1394
+ .test-item.filtered-out {
1395
+ display: none !important;
1396
+ }
1397
+
1398
+ /* System Info Section */
1399
+ .system-info-section {
1400
+ background: white;
1401
+ margin-bottom: 2rem;
1402
+ border-radius: 8px;
1403
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1404
+ overflow: hidden;
1405
+ }
1406
+
1407
+ .system-info-header {
1408
+ background: #2c3e50;
1409
+ color: white;
1410
+ padding: 1rem;
1411
+ cursor: pointer;
1412
+ display: flex;
1413
+ justify-content: space-between;
1414
+ align-items: center;
1415
+ transition: background-color 0.2s;
1416
+ }
1417
+
1418
+ .system-info-header:hover {
1419
+ background: #34495e;
1420
+ }
1421
+
1422
+ .system-info-header h3 {
1423
+ margin: 0;
1424
+ font-size: 1.2rem;
1425
+ }
1426
+
1427
+ .toggle-icon {
1428
+ font-size: 1rem;
1429
+ transition: transform 0.3s ease;
1430
+ }
1431
+
1432
+ .toggle-icon.rotated {
1433
+ transform: rotate(-180deg);
1434
+ }
1435
+
1436
+ .system-info-content {
1437
+ display: none;
1438
+ padding: 1.5rem;
1439
+ background: #f8f9fa;
1440
+ border-top: 1px solid #e9ecef;
1441
+ }
1442
+
1443
+ .system-info-content.visible {
1444
+ display: block;
1445
+ }
1446
+
1447
+ .system-info-grid {
1448
+ display: grid;
1449
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
1450
+ gap: 1rem;
1451
+ }
1452
+
1453
+ .info-item {
1454
+ padding: 0.75rem;
1455
+ background: white;
1456
+ border-radius: 6px;
1457
+ border-left: 4px solid #3498db;
1458
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
1459
+ }
1460
+
1461
+ .info-key {
1462
+ font-weight: bold;
1463
+ color: #2c3e50;
1464
+ display: inline-block;
1465
+ min-width: 100px;
1466
+ }
1467
+
1468
+ .info-value {
1469
+ color: #34495e;
1470
+ font-family: 'Courier New', monospace;
1471
+ font-size: 0.9rem;
1472
+ }
1473
+
1474
+ /* BDD/Gherkin specific styles */
1475
+ .bdd-test {
1476
+ border-left: 4px solid #8e44ad;
1477
+ }
1478
+
1479
+ .bdd-badge {
1480
+ background: #8e44ad;
1481
+ color: white;
1482
+ padding: 0.25rem 0.5rem;
1483
+ border-radius: 4px;
1484
+ font-size: 0.7rem;
1485
+ font-weight: bold;
1486
+ }
1487
+
1488
+ .bdd-feature-section {
1489
+ margin-top: 1rem;
1490
+ padding: 1rem;
1491
+ background: #f8f9fa;
1492
+ border-left: 4px solid #8e44ad;
1493
+ border-radius: 4px;
1494
+ }
1495
+
1496
+ .feature-name {
1497
+ font-weight: bold;
1498
+ font-size: 1.1rem;
1499
+ color: #8e44ad;
1500
+ margin-bottom: 0.5rem;
1501
+ }
1502
+
1503
+ .feature-description {
1504
+ color: #34495e;
1505
+ font-style: italic;
1506
+ margin: 0.5rem 0;
1507
+ padding: 0.5rem;
1508
+ background: white;
1509
+ border-radius: 4px;
1510
+ }
1511
+
1512
+ .feature-file {
1513
+ font-size: 0.8rem;
1514
+ color: #7f8c8d;
1515
+ margin-top: 0.5rem;
1516
+ }
1517
+
1518
+ .feature-tags {
1519
+ display: flex;
1520
+ flex-wrap: wrap;
1521
+ gap: 0.25rem;
1522
+ margin: 0.5rem 0;
1523
+ }
1524
+
1525
+ .feature-tag {
1526
+ background: #8e44ad;
1527
+ color: white;
1528
+ padding: 0.2rem 0.4rem;
1529
+ border-radius: 8px;
1530
+ font-size: 0.7rem;
1531
+ }
1532
+
1533
+ .bdd-steps-section {
1534
+ margin-top: 1rem;
1535
+ }
1536
+
1537
+ .bdd-steps-section h4 {
1538
+ color: #8e44ad;
1539
+ margin-bottom: 0.5rem;
1540
+ font-size: 1rem;
1541
+ }
1542
+
1543
+ .bdd-step-item {
1544
+ display: flex;
1545
+ align-items: flex-start;
1546
+ padding: 0.5rem 0;
1547
+ border-bottom: 1px solid #ecf0f1;
1548
+ font-family: 'Segoe UI', sans-serif;
1549
+ }
1550
+
1551
+ .bdd-step-item:last-child {
1552
+ border-bottom: none;
1553
+ }
1554
+
1555
+ .bdd-keyword {
1556
+ font-weight: bold;
1557
+ color: #8e44ad;
1558
+ margin-right: 0.5rem;
1559
+ min-width: 60px;
1560
+ text-align: left;
1561
+ }
1562
+
1563
+ .bdd-step-text {
1564
+ flex: 1;
1565
+ color: #2c3e50;
1566
+ margin-right: 0.5rem;
1567
+ }
1568
+
1569
+ .step-comment {
1570
+ width: 100%;
1571
+ margin-top: 0.5rem;
1572
+ padding: 0.5rem;
1573
+ background: #f8f9fa;
1574
+ border: 1px solid #e9ecef;
1575
+ border-radius: 4px;
1576
+ color: #6c757d;
1577
+ font-family: 'Courier New', monospace;
1578
+ font-size: 0.8rem;
1579
+ white-space: pre-wrap;
1580
+ }
1581
+
1582
+ @media (max-width: 768px) {
1583
+ .stats-cards {
1584
+ flex-direction: column;
1585
+ }
1586
+
1587
+ .test-header {
1588
+ flex-direction: column;
1589
+ align-items: stretch;
1590
+ gap: 0.5rem;
1591
+ }
1592
+
1593
+ .test-feature, .test-duration {
1594
+ align-self: flex-start;
1595
+ }
1596
+ }
1597
+ `
1598
+ }
1599
+
1600
+ function getJsScripts() {
1601
+ return `
1602
+ function toggleTestDetails(testId) {
1603
+ const details = document.getElementById('details-' + testId);
1604
+ if (details.style.display === 'none' || details.style.display === '') {
1605
+ details.style.display = 'block';
1606
+ } else {
1607
+ details.style.display = 'none';
1608
+ }
1609
+ }
1610
+
1611
+ function openImageModal(src) {
1612
+ const modal = document.getElementById('imageModal');
1613
+ const modalImg = document.getElementById('modalImage');
1614
+ modalImg.src = src;
1615
+ modal.style.display = 'block';
1616
+ }
1617
+
1618
+ function closeImageModal() {
1619
+ const modal = document.getElementById('imageModal');
1620
+ modal.style.display = 'none';
1621
+ }
1622
+
1623
+ function toggleSystemInfo() {
1624
+ const content = document.getElementById('systemInfoContent');
1625
+ const icon = document.querySelector('.toggle-icon');
1626
+
1627
+ if (content.classList.contains('visible')) {
1628
+ content.classList.remove('visible');
1629
+ icon.classList.remove('rotated');
1630
+ } else {
1631
+ content.classList.add('visible');
1632
+ icon.classList.add('rotated');
1633
+ }
1634
+ }
1635
+
1636
+ // Filter functionality
1637
+ function applyFilters() {
1638
+ const statusFilter = Array.from(document.getElementById('statusFilter').selectedOptions).map(opt => opt.value);
1639
+ const featureFilter = document.getElementById('featureFilter').value.toLowerCase();
1640
+ const tagFilter = document.getElementById('tagFilter').value.toLowerCase();
1641
+ const retryFilter = document.getElementById('retryFilter').value;
1642
+ const typeFilter = document.getElementById('typeFilter').value;
1643
+
1644
+ const testItems = document.querySelectorAll('.test-item');
1645
+
1646
+ testItems.forEach(item => {
1647
+ let shouldShow = true;
1648
+
1649
+ // Status filter
1650
+ if (statusFilter.length > 0) {
1651
+ const testStatus = item.dataset.status;
1652
+ if (!statusFilter.includes(testStatus)) {
1653
+ shouldShow = false;
1654
+ }
1655
+ }
1656
+
1657
+ // Feature filter
1658
+ if (featureFilter && shouldShow) {
1659
+ const feature = (item.dataset.feature || '').toLowerCase();
1660
+ if (!feature.includes(featureFilter)) {
1661
+ shouldShow = false;
1662
+ }
1663
+ }
1664
+
1665
+ // Tag filter
1666
+ if (tagFilter && shouldShow) {
1667
+ const tags = (item.dataset.tags || '').toLowerCase();
1668
+ if (!tags.includes(tagFilter)) {
1669
+ shouldShow = false;
1670
+ }
1671
+ }
1672
+
1673
+ // Retry filter
1674
+ if (retryFilter !== 'all' && shouldShow) {
1675
+ const retries = parseInt(item.dataset.retries || '0');
1676
+ if (retryFilter === 'retried' && retries === 0) {
1677
+ shouldShow = false;
1678
+ } else if (retryFilter === 'no-retries' && retries > 0) {
1679
+ shouldShow = false;
1680
+ }
1681
+ }
1682
+
1683
+ // Test type filter (BDD/Gherkin vs Regular)
1684
+ if (typeFilter !== 'all' && shouldShow) {
1685
+ const testType = item.dataset.type || 'regular';
1686
+ if (typeFilter !== testType) {
1687
+ shouldShow = false;
1688
+ }
1689
+ }
1690
+
1691
+ if (shouldShow) {
1692
+ item.classList.remove('filtered-out');
1693
+ } else {
1694
+ item.classList.add('filtered-out');
1695
+ }
1696
+ });
1697
+
1698
+ updateFilteredStats();
1699
+ }
1700
+
1701
+ function resetFilters() {
1702
+ document.getElementById('statusFilter').selectedIndex = -1;
1703
+ document.getElementById('featureFilter').value = '';
1704
+ document.getElementById('tagFilter').value = '';
1705
+ document.getElementById('retryFilter').value = 'all';
1706
+ document.getElementById('typeFilter').value = 'all';
1707
+
1708
+ document.querySelectorAll('.test-item').forEach(item => {
1709
+ item.classList.remove('filtered-out');
1710
+ });
1711
+
1712
+ updateFilteredStats();
1713
+ }
1714
+
1715
+ function updateFilteredStats() {
1716
+ const visibleTests = document.querySelectorAll('.test-item:not(.filtered-out)');
1717
+ const totalVisible = visibleTests.length;
1718
+
1719
+ // Update the title to show filtered count
1720
+ const testsSection = document.querySelector('.tests-section h2');
1721
+ const totalTests = document.querySelectorAll('.test-item').length;
1722
+
1723
+ if (totalVisible !== totalTests) {
1724
+ testsSection.textContent = 'Test Results (' + totalVisible + ' of ' + totalTests + ' shown)';
1725
+ } else {
1726
+ testsSection.textContent = 'Test Results';
1727
+ }
1728
+ }
1729
+
1730
+ // Draw pie chart using canvas
1731
+ function drawPieChart() {
1732
+ const canvas = document.getElementById('statsChart');
1733
+ if (!canvas) return;
1734
+
1735
+ const ctx = canvas.getContext('2d');
1736
+ const data = window.chartData;
1737
+
1738
+ if (!data) return;
1739
+
1740
+ const centerX = canvas.width / 2;
1741
+ const centerY = canvas.height / 2;
1742
+ const radius = Math.min(centerX, centerY) - 20;
1743
+
1744
+ const total = data.passed + data.failed + data.pending;
1745
+ if (total === 0) {
1746
+ // Draw empty circle for no tests
1747
+ ctx.strokeStyle = '#ddd';
1748
+ ctx.lineWidth = 2;
1749
+ ctx.beginPath();
1750
+ ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
1751
+ ctx.stroke();
1752
+ ctx.fillStyle = '#888';
1753
+ ctx.font = '16px Arial';
1754
+ ctx.textAlign = 'center';
1755
+ ctx.fillText('No Tests', centerX, centerY);
1756
+ return;
1757
+ }
1758
+
1759
+ let currentAngle = -Math.PI / 2; // Start from top
1760
+
1761
+ // Draw passed segment
1762
+ if (data.passed > 0) {
1763
+ const angle = (data.passed / total) * 2 * Math.PI;
1764
+ ctx.beginPath();
1765
+ ctx.moveTo(centerX, centerY);
1766
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
1767
+ ctx.closePath();
1768
+ ctx.fillStyle = '#27ae60';
1769
+ ctx.fill();
1770
+ currentAngle += angle;
1771
+ }
1772
+
1773
+ // Draw failed segment
1774
+ if (data.failed > 0) {
1775
+ const angle = (data.failed / total) * 2 * Math.PI;
1776
+ ctx.beginPath();
1777
+ ctx.moveTo(centerX, centerY);
1778
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
1779
+ ctx.closePath();
1780
+ ctx.fillStyle = '#e74c3c';
1781
+ ctx.fill();
1782
+ currentAngle += angle;
1783
+ }
1784
+
1785
+ // Draw pending segment
1786
+ if (data.pending > 0) {
1787
+ const angle = (data.pending / total) * 2 * Math.PI;
1788
+ ctx.beginPath();
1789
+ ctx.moveTo(centerX, centerY);
1790
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
1791
+ ctx.closePath();
1792
+ ctx.fillStyle = '#f39c12';
1793
+ ctx.fill();
1794
+ }
1795
+
1796
+ // Add legend
1797
+ const legendY = centerY + radius + 40;
1798
+ ctx.font = '14px Arial';
1799
+ ctx.textAlign = 'left';
1800
+
1801
+ let legendX = centerX - 120;
1802
+
1803
+ // Passed legend
1804
+ ctx.fillStyle = '#27ae60';
1805
+ ctx.fillRect(legendX, legendY, 15, 15);
1806
+ ctx.fillStyle = '#333';
1807
+ ctx.fillText('Passed (' + data.passed + ')', legendX + 20, legendY + 12);
1808
+
1809
+ // Failed legend
1810
+ legendX += 100;
1811
+ ctx.fillStyle = '#e74c3c';
1812
+ ctx.fillRect(legendX, legendY, 15, 15);
1813
+ ctx.fillStyle = '#333';
1814
+ ctx.fillText('Failed (' + data.failed + ')', legendX + 20, legendY + 12);
1815
+
1816
+ // Pending legend
1817
+ if (data.pending > 0) {
1818
+ legendX += 90;
1819
+ ctx.fillStyle = '#f39c12';
1820
+ ctx.fillRect(legendX, legendY, 15, 15);
1821
+ ctx.fillStyle = '#333';
1822
+ ctx.fillText('Pending (' + data.pending + ')', legendX + 20, legendY + 12);
1823
+ }
1824
+ }
1825
+
1826
+ // Draw history chart
1827
+ function drawHistoryChart() {
1828
+ const canvas = document.getElementById('historyChart');
1829
+ if (!canvas || !window.testData.history || window.testData.history.length === 0) return;
1830
+
1831
+ const ctx = canvas.getContext('2d');
1832
+ const history = window.testData.history.slice().reverse(); // Most recent last
1833
+
1834
+ const padding = 50;
1835
+ const chartWidth = canvas.width - 2 * padding;
1836
+ const chartHeight = canvas.height - 2 * padding;
1837
+
1838
+ // Clear canvas
1839
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1840
+
1841
+ // Find max values
1842
+ const maxTests = Math.max(...history.map(h => h.stats.tests || 0));
1843
+ const maxDuration = Math.max(...history.map(h => h.duration || 0));
1844
+
1845
+ if (maxTests === 0) return;
1846
+
1847
+ // Draw axes
1848
+ ctx.strokeStyle = '#333';
1849
+ ctx.lineWidth = 1;
1850
+ ctx.beginPath();
1851
+ ctx.moveTo(padding, padding);
1852
+ ctx.lineTo(padding, canvas.height - padding);
1853
+ ctx.lineTo(canvas.width - padding, canvas.height - padding);
1854
+ ctx.stroke();
1855
+
1856
+ // Draw grid lines
1857
+ ctx.strokeStyle = '#eee';
1858
+ ctx.lineWidth = 1;
1859
+ for (let i = 1; i <= 5; i++) {
1860
+ const y = padding + (chartHeight * i / 5);
1861
+ ctx.beginPath();
1862
+ ctx.moveTo(padding, y);
1863
+ ctx.lineTo(canvas.width - padding, y);
1864
+ ctx.stroke();
1865
+ }
1866
+
1867
+ // Draw pass/fail rates
1868
+ const stepX = chartWidth / (history.length - 1);
1869
+
1870
+ // Draw passed tests line
1871
+ ctx.strokeStyle = '#27ae60';
1872
+ ctx.lineWidth = 3;
1873
+ ctx.beginPath();
1874
+ history.forEach((run, index) => {
1875
+ const x = padding + (index * stepX);
1876
+ const y = canvas.height - padding - ((run.stats.passes || 0) / maxTests) * chartHeight;
1877
+ if (index === 0) {
1878
+ ctx.moveTo(x, y);
1879
+ } else {
1880
+ ctx.lineTo(x, y);
1881
+ }
1882
+ });
1883
+ ctx.stroke();
1884
+
1885
+ // Draw failed tests line
1886
+ ctx.strokeStyle = '#e74c3c';
1887
+ ctx.lineWidth = 3;
1888
+ ctx.beginPath();
1889
+ history.forEach((run, index) => {
1890
+ const x = padding + (index * stepX);
1891
+ const y = canvas.height - padding - ((run.stats.failures || 0) / maxTests) * chartHeight;
1892
+ if (index === 0) {
1893
+ ctx.moveTo(x, y);
1894
+ } else {
1895
+ ctx.lineTo(x, y);
1896
+ }
1897
+ });
1898
+ ctx.stroke();
1899
+
1900
+ // Add labels
1901
+ ctx.fillStyle = '#333';
1902
+ ctx.font = '12px Arial';
1903
+ ctx.textAlign = 'center';
1904
+
1905
+ // Y-axis labels
1906
+ ctx.textAlign = 'right';
1907
+ for (let i = 0; i <= 5; i++) {
1908
+ const value = Math.round((maxTests * i) / 5);
1909
+ const y = canvas.height - padding - (chartHeight * i / 5);
1910
+ ctx.fillText(value.toString(), padding - 10, y + 4);
1911
+ }
1912
+
1913
+ // Legend
1914
+ ctx.textAlign = 'left';
1915
+ ctx.fillStyle = '#27ae60';
1916
+ ctx.fillRect(padding, 20, 15, 15);
1917
+ ctx.fillStyle = '#333';
1918
+ ctx.fillText('Passed Tests', padding + 20, 32);
1919
+
1920
+ ctx.fillStyle = '#e74c3c';
1921
+ ctx.fillRect(padding + 120, 20, 15, 15);
1922
+ ctx.fillStyle = '#333';
1923
+ ctx.fillText('Failed Tests', padding + 140, 32);
1924
+ }
1925
+
1926
+ // Initialize - hide failures section if no failures and draw charts
1927
+ document.addEventListener('DOMContentLoaded', function() {
1928
+ const failuresSection = document.querySelector('.failures-section');
1929
+ const failureItems = document.querySelectorAll('.failure-item');
1930
+ if (failureItems.length === 0) {
1931
+ failuresSection.style.display = 'none';
1932
+ }
1933
+
1934
+ // Draw charts
1935
+ drawPieChart();
1936
+ drawHistoryChart();
1937
+
1938
+ // Set up filter event listeners
1939
+ document.getElementById('statusFilter').addEventListener('change', applyFilters);
1940
+ document.getElementById('featureFilter').addEventListener('input', applyFilters);
1941
+ document.getElementById('tagFilter').addEventListener('input', applyFilters);
1942
+ document.getElementById('retryFilter').addEventListener('change', applyFilters);
1943
+ document.getElementById('typeFilter').addEventListener('change', applyFilters);
1944
+ });
1945
+ `
1946
+ }
1947
+ }