codeceptjs 3.7.4 → 3.7.5-beta.1

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.
@@ -0,0 +1,2955 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const mkdirp = require('mkdirp')
4
+ const crypto = require('crypto')
5
+ const { threadId } = require('worker_threads')
6
+ const { template } = require('../utils')
7
+ const { getMachineInfo } = require('../command/info')
8
+
9
+ const event = require('../event')
10
+ const output = require('../output')
11
+ const Codecept = require('../codecept')
12
+
13
+ const defaultConfig = {
14
+ output: global.output_dir || './output',
15
+ reportFileName: 'report.html',
16
+ includeArtifacts: true,
17
+ showSteps: true,
18
+ showSkipped: true,
19
+ showMetadata: true,
20
+ showTags: true,
21
+ showRetries: true,
22
+ exportStats: false,
23
+ exportStatsPath: './stats.json',
24
+ keepHistory: false,
25
+ historyPath: './test-history.json',
26
+ maxHistoryEntries: 50,
27
+ }
28
+
29
+ /**
30
+ * HTML Reporter Plugin for CodeceptJS
31
+ *
32
+ * Generates comprehensive HTML reports showing:
33
+ * - Test statistics
34
+ * - Feature/Scenario details
35
+ * - Individual step results
36
+ * - Test artifacts (screenshots, etc.)
37
+ *
38
+ * ## Configuration
39
+ *
40
+ * ```js
41
+ * "plugins": {
42
+ * "htmlReporter": {
43
+ * "enabled": true,
44
+ * "output": "./output",
45
+ * "reportFileName": "report.html",
46
+ * "includeArtifacts": true,
47
+ * "showSteps": true,
48
+ * "showSkipped": true,
49
+ * "showMetadata": true,
50
+ * "showTags": true,
51
+ * "showRetries": true,
52
+ * "exportStats": false,
53
+ * "exportStatsPath": "./stats.json",
54
+ * "keepHistory": false,
55
+ * "historyPath": "./test-history.json",
56
+ * "maxHistoryEntries": 50
57
+ * }
58
+ * }
59
+ * ```
60
+ */
61
+ module.exports = function (config) {
62
+ const options = { ...defaultConfig, ...config }
63
+ let reportData = {
64
+ stats: {},
65
+ tests: [],
66
+ failures: [],
67
+ hooks: [],
68
+ startTime: null,
69
+ endTime: null,
70
+ retries: [],
71
+ config: options,
72
+ }
73
+ let currentTestSteps = []
74
+ let currentTestHooks = []
75
+ let currentBddSteps = [] // Track BDD/Gherkin steps
76
+ let testRetryAttempts = new Map() // Track retry attempts per test
77
+ let currentSuite = null // Track current suite for BDD detection
78
+
79
+ // Initialize report directory
80
+ const reportDir = options.output ? path.resolve(global.codecept_dir, options.output) : path.resolve(global.output_dir || './output')
81
+ mkdirp.sync(reportDir)
82
+
83
+ // Track overall test execution
84
+ event.dispatcher.on(event.all.before, () => {
85
+ reportData.startTime = new Date()
86
+ output.plugin('htmlReporter', 'Starting HTML report generation...')
87
+ })
88
+
89
+ // Track test start to initialize steps and hooks collection
90
+ event.dispatcher.on(event.test.before, test => {
91
+ currentTestSteps = []
92
+ currentTestHooks = []
93
+ currentBddSteps = []
94
+
95
+ // Track current suite for BDD detection
96
+ currentSuite = test.parent
97
+
98
+ // Enhanced retry detection with priority-based approach
99
+ const testId = generateTestId(test)
100
+
101
+ // Only set retry count if not already set, using priority order
102
+ if (!testRetryAttempts.has(testId)) {
103
+ // Method 1: Check retryNum property (most reliable)
104
+ if (test.retryNum && test.retryNum > 0) {
105
+ testRetryAttempts.set(testId, test.retryNum)
106
+ output.print(`HTML Reporter: Retry count detected (retryNum) for ${test.title}, attempts: ${test.retryNum}`)
107
+ }
108
+ // Method 2: Check currentRetry property
109
+ else if (test.currentRetry && test.currentRetry > 0) {
110
+ testRetryAttempts.set(testId, test.currentRetry)
111
+ output.print(`HTML Reporter: Retry count detected (currentRetry) for ${test.title}, attempts: ${test.currentRetry}`)
112
+ }
113
+ // Method 3: Check if this is a retried test
114
+ else if (test.retriedTest && test.retriedTest()) {
115
+ const originalTest = test.retriedTest()
116
+ const originalTestId = generateTestId(originalTest)
117
+ if (!testRetryAttempts.has(originalTestId)) {
118
+ testRetryAttempts.set(originalTestId, 1) // Start with 1 retry
119
+ } else {
120
+ testRetryAttempts.set(originalTestId, testRetryAttempts.get(originalTestId) + 1)
121
+ }
122
+ output.print(`HTML Reporter: Retry detected (retriedTest) for ${originalTest.title}, attempts: ${testRetryAttempts.get(originalTestId)}`)
123
+ }
124
+ // Method 4: Check if test has been seen before (indicating a retry)
125
+ else if (reportData.tests.some(t => t.id === testId)) {
126
+ testRetryAttempts.set(testId, 1) // First retry detected
127
+ output.print(`HTML Reporter: Retry detected (duplicate test) for ${test.title}, attempts: 1`)
128
+ }
129
+ }
130
+ })
131
+
132
+ // Collect step information
133
+ event.dispatcher.on(event.step.started, step => {
134
+ step.htmlReporterStartTime = Date.now()
135
+ })
136
+
137
+ event.dispatcher.on(event.step.finished, step => {
138
+ if (step.htmlReporterStartTime) {
139
+ step.duration = Date.now() - step.htmlReporterStartTime
140
+ }
141
+ currentTestSteps.push({
142
+ name: step.name,
143
+ actor: step.actor,
144
+ args: step.args || [],
145
+ status: step.failed ? 'failed' : 'success',
146
+ duration: step.duration || 0,
147
+ })
148
+ })
149
+
150
+ // Collect hook information
151
+ event.dispatcher.on(event.hook.started, hook => {
152
+ hook.htmlReporterStartTime = Date.now()
153
+ })
154
+
155
+ event.dispatcher.on(event.hook.finished, hook => {
156
+ if (hook.htmlReporterStartTime) {
157
+ hook.duration = Date.now() - hook.htmlReporterStartTime
158
+ }
159
+ const hookInfo = {
160
+ title: hook.title,
161
+ type: hook.type || 'unknown', // before, after, beforeSuite, afterSuite
162
+ status: hook.err ? 'failed' : 'passed',
163
+ duration: hook.duration || 0,
164
+ error: hook.err ? hook.err.message : null,
165
+ }
166
+ currentTestHooks.push(hookInfo)
167
+ reportData.hooks.push(hookInfo)
168
+ })
169
+
170
+ // Collect BDD/Gherkin step information
171
+ event.dispatcher.on(event.bddStep.started, step => {
172
+ step.htmlReporterStartTime = Date.now()
173
+ })
174
+
175
+ event.dispatcher.on(event.bddStep.finished, step => {
176
+ if (step.htmlReporterStartTime) {
177
+ step.duration = Date.now() - step.htmlReporterStartTime
178
+ }
179
+ currentBddSteps.push({
180
+ keyword: step.actor || 'Given',
181
+ text: step.name,
182
+ status: step.failed ? 'failed' : 'success',
183
+ duration: step.duration || 0,
184
+ comment: step.comment,
185
+ })
186
+ })
187
+
188
+ // Collect test results
189
+ event.dispatcher.on(event.test.finished, test => {
190
+ const testId = generateTestId(test)
191
+ let retryAttempts = testRetryAttempts.get(testId) || 0
192
+
193
+ // Additional retry detection in test.finished event
194
+ // Check if this test has retry indicators we might have missed
195
+ if (retryAttempts === 0) {
196
+ if (test.retryNum && test.retryNum > 0) {
197
+ retryAttempts = test.retryNum
198
+ testRetryAttempts.set(testId, retryAttempts)
199
+ output.print(`HTML Reporter: Late retry detection (retryNum) for ${test.title}, attempts: ${retryAttempts}`)
200
+ } else if (test.currentRetry && test.currentRetry > 0) {
201
+ retryAttempts = test.currentRetry
202
+ testRetryAttempts.set(testId, retryAttempts)
203
+ output.print(`HTML Reporter: Late retry detection (currentRetry) for ${test.title}, attempts: ${retryAttempts}`)
204
+ } else if (test._retries && test._retries > 0) {
205
+ retryAttempts = test._retries
206
+ testRetryAttempts.set(testId, retryAttempts)
207
+ output.print(`HTML Reporter: Late retry detection (_retries) for ${test.title}, attempts: ${retryAttempts}`)
208
+ }
209
+ }
210
+
211
+ // Debug logging
212
+ output.print(`HTML Reporter: Test finished - ${test.title}, State: ${test.state}, Retries: ${retryAttempts}`)
213
+
214
+ // Detect if this is a BDD/Gherkin test
215
+ const isBddTest = isBddGherkinTest(test, currentSuite)
216
+ const steps = isBddTest ? currentBddSteps : currentTestSteps
217
+ const featureInfo = isBddTest ? getBddFeatureInfo(test, currentSuite) : null
218
+
219
+ // Check if this test already exists in reportData.tests (from a previous retry)
220
+ const existingTestIndex = reportData.tests.findIndex(t => t.id === testId)
221
+ const hasFailedBefore = existingTestIndex >= 0 && reportData.tests[existingTestIndex].state === 'failed'
222
+ const currentlyFailed = test.state === 'failed'
223
+
224
+ // Debug artifacts collection (but don't process them yet - screenshots may not be ready)
225
+ output.print(`HTML Reporter: Test ${test.title} artifacts at test.finished: ${JSON.stringify(test.artifacts)}`)
226
+
227
+ const testData = {
228
+ ...test,
229
+ id: testId,
230
+ duration: test.duration || 0,
231
+ steps: [...steps], // Copy the steps (BDD or regular)
232
+ hooks: [...currentTestHooks], // Copy the hooks
233
+ artifacts: test.artifacts || [], // Keep original artifacts for now
234
+ tags: test.tags || [],
235
+ meta: test.meta || {},
236
+ opts: test.opts || {},
237
+ notes: test.notes || [],
238
+ retryAttempts: currentlyFailed || hasFailedBefore ? retryAttempts : 0, // Only show retries for failed tests
239
+ uid: test.uid,
240
+ isBdd: isBddTest,
241
+ feature: featureInfo,
242
+ }
243
+
244
+ if (existingTestIndex >= 0) {
245
+ // Update existing test with final result (including failed state)
246
+ reportData.tests[existingTestIndex] = testData
247
+ output.print(`HTML Reporter: Updated existing test - ${test.title}, Final state: ${test.state}`)
248
+ } else {
249
+ // Add new test
250
+ reportData.tests.push(testData)
251
+ output.print(`HTML Reporter: Added new test - ${test.title}, State: ${test.state}`)
252
+ }
253
+
254
+ // Track retry information - only add if there were actual retries AND the test failed at some point
255
+ const existingRetryIndex = reportData.retries.findIndex(r => r.testId === testId)
256
+
257
+ // Only track retries if:
258
+ // 1. There are retry attempts detected AND (test failed now OR failed before)
259
+ // 2. OR there's an existing retry record (meaning it failed before)
260
+ if ((retryAttempts > 0 && (currentlyFailed || hasFailedBefore)) || existingRetryIndex >= 0) {
261
+ // If no retry attempts detected but we have an existing retry record, increment it
262
+ if (retryAttempts === 0 && existingRetryIndex >= 0) {
263
+ retryAttempts = reportData.retries[existingRetryIndex].attempts + 1
264
+ testRetryAttempts.set(testId, retryAttempts)
265
+ output.print(`HTML Reporter: Incremented retry count for duplicate test ${test.title}, attempts: ${retryAttempts}`)
266
+ }
267
+
268
+ // Remove existing retry info for this test and add updated one
269
+ reportData.retries = reportData.retries.filter(r => r.testId !== testId)
270
+ reportData.retries.push({
271
+ testId: testId,
272
+ testTitle: test.title,
273
+ attempts: retryAttempts,
274
+ finalState: test.state,
275
+ duration: test.duration || 0,
276
+ })
277
+ output.print(`HTML Reporter: Added retry info for ${test.title}, attempts: ${retryAttempts}, state: ${test.state}`)
278
+ }
279
+
280
+ // Fallback: If this test already exists and either failed before or is failing now, it's a retry
281
+ else if (existingTestIndex >= 0 && (hasFailedBefore || currentlyFailed)) {
282
+ const fallbackAttempts = 1
283
+ testRetryAttempts.set(testId, fallbackAttempts)
284
+ reportData.retries.push({
285
+ testId: testId,
286
+ testTitle: test.title,
287
+ attempts: fallbackAttempts,
288
+ finalState: test.state,
289
+ duration: test.duration || 0,
290
+ })
291
+ output.print(`HTML Reporter: Fallback retry detection for failed test ${test.title}, attempts: ${fallbackAttempts}`)
292
+ }
293
+ })
294
+
295
+ // Generate final report
296
+ event.dispatcher.on(event.all.result, result => {
297
+ reportData.endTime = new Date()
298
+ reportData.duration = reportData.endTime - reportData.startTime
299
+
300
+ // Process artifacts now that all async tasks (including screenshots) are complete
301
+ output.print(`HTML Reporter: Processing artifacts for ${reportData.tests.length} tests after all async tasks complete`)
302
+
303
+ reportData.tests.forEach(test => {
304
+ const originalArtifacts = test.artifacts
305
+ let collectedArtifacts = []
306
+
307
+ output.print(`HTML Reporter: Processing test "${test.title}" (ID: ${test.id})`)
308
+ output.print(`HTML Reporter: Test ${test.title} final artifacts: ${JSON.stringify(originalArtifacts)}`)
309
+
310
+ if (originalArtifacts) {
311
+ if (Array.isArray(originalArtifacts)) {
312
+ collectedArtifacts = originalArtifacts
313
+ output.print(`HTML Reporter: Using array artifacts: ${collectedArtifacts.length} items`)
314
+ } else if (typeof originalArtifacts === 'object') {
315
+ // Convert object properties to array (screenshotOnFail plugin format)
316
+ collectedArtifacts = Object.values(originalArtifacts).filter(artifact => artifact)
317
+ output.print(`HTML Reporter: Converted artifacts object to array: ${collectedArtifacts.length} items`)
318
+ output.print(`HTML Reporter: Converted artifacts: ${JSON.stringify(collectedArtifacts)}`)
319
+ }
320
+ }
321
+
322
+ // Only use filesystem fallback if no artifacts found from screenshotOnFail plugin
323
+ if (collectedArtifacts.length === 0 && test.state === 'failed') {
324
+ output.print(`HTML Reporter: No artifacts from plugin, trying filesystem for test "${test.title}"`)
325
+ collectedArtifacts = collectScreenshotsFromFilesystem(test, test.id)
326
+ output.print(`HTML Reporter: Collected ${collectedArtifacts.length} screenshots from filesystem for failed test "${test.title}"`)
327
+ if (collectedArtifacts.length > 0) {
328
+ output.print(`HTML Reporter: Filesystem screenshots for "${test.title}": ${JSON.stringify(collectedArtifacts)}`)
329
+ }
330
+ }
331
+
332
+ // Update test with processed artifacts
333
+ test.artifacts = collectedArtifacts
334
+ output.print(`HTML Reporter: Final artifacts for "${test.title}": ${JSON.stringify(test.artifacts)}`)
335
+ })
336
+
337
+ // Calculate stats from our collected test data instead of using result.stats
338
+ const passedTests = reportData.tests.filter(t => t.state === 'passed').length
339
+ const failedTests = reportData.tests.filter(t => t.state === 'failed').length
340
+ const pendingTests = reportData.tests.filter(t => t.state === 'pending').length
341
+ const skippedTests = reportData.tests.filter(t => t.state === 'skipped').length
342
+
343
+ // Populate failures from our collected test data with enhanced details
344
+ reportData.failures = reportData.tests
345
+ .filter(t => t.state === 'failed')
346
+ .map(t => {
347
+ const testName = t.title || 'Unknown Test'
348
+ const featureName = t.parent?.title || 'Unknown Feature'
349
+
350
+ if (t.err) {
351
+ const errorMessage = t.err.message || t.err.toString() || 'Test failed'
352
+ const errorStack = t.err.stack || ''
353
+ const filePath = t.file || t.parent?.file || ''
354
+
355
+ // Create enhanced failure object with test details
356
+ return {
357
+ testName: testName,
358
+ featureName: featureName,
359
+ message: errorMessage,
360
+ stack: errorStack,
361
+ filePath: filePath,
362
+ toString: () => `${testName} (${featureName})\n${errorMessage}\n${errorStack}`.trim(),
363
+ }
364
+ }
365
+
366
+ return {
367
+ testName: testName,
368
+ featureName: featureName,
369
+ message: `Test failed: ${testName}`,
370
+ stack: '',
371
+ filePath: t.file || t.parent?.file || '',
372
+ toString: () => `${testName} (${featureName})\nTest failed: ${testName}`,
373
+ }
374
+ })
375
+
376
+ reportData.stats = {
377
+ tests: reportData.tests.length,
378
+ passes: passedTests,
379
+ failures: failedTests,
380
+ pending: pendingTests,
381
+ skipped: skippedTests,
382
+ duration: reportData.duration,
383
+ failedHooks: result.stats?.failedHooks || 0,
384
+ }
385
+
386
+ // Debug logging for final stats
387
+ output.print(`HTML Reporter: Calculated stats - Tests: ${reportData.stats.tests}, Passes: ${reportData.stats.passes}, Failures: ${reportData.stats.failures}`)
388
+ output.print(`HTML Reporter: Collected ${reportData.tests.length} tests in reportData`)
389
+ output.print(`HTML Reporter: Failures array has ${reportData.failures.length} items`)
390
+ output.print(`HTML Reporter: Retries array has ${reportData.retries.length} items`)
391
+ output.print(`HTML Reporter: testRetryAttempts Map size: ${testRetryAttempts.size}`)
392
+
393
+ // Log retry attempts map contents
394
+ for (const [testId, attempts] of testRetryAttempts.entries()) {
395
+ output.print(`HTML Reporter: testRetryAttempts - ${testId}: ${attempts} attempts`)
396
+ }
397
+
398
+ reportData.tests.forEach(test => {
399
+ output.print(`HTML Reporter: Test in reportData - ${test.title}, State: ${test.state}, Retries: ${test.retryAttempts}`)
400
+ })
401
+
402
+ // Check if running with workers
403
+ if (process.env.RUNS_WITH_WORKERS) {
404
+ // In worker mode, save results to a JSON file for later consolidation
405
+ const workerId = threadId
406
+ const jsonFileName = `worker-${workerId}-results.json`
407
+ const jsonPath = path.join(reportDir, jsonFileName)
408
+
409
+ try {
410
+ // Always overwrite the file with the latest complete data from this worker
411
+ // This prevents double-counting when the event is triggered multiple times
412
+ fs.writeFileSync(jsonPath, safeJsonStringify(reportData))
413
+ output.print(`HTML Reporter: Generated worker JSON results: ${jsonFileName}`)
414
+ } catch (error) {
415
+ output.print(`HTML Reporter: Failed to write worker JSON: ${error.message}`)
416
+ }
417
+ return
418
+ }
419
+
420
+ // Single process mode - generate report normally
421
+ generateHtmlReport(reportData, options)
422
+
423
+ // Export stats if configured
424
+ if (options.exportStats) {
425
+ exportTestStats(reportData, options)
426
+ }
427
+
428
+ // Save history if configured
429
+ if (options.keepHistory) {
430
+ saveTestHistory(reportData, options)
431
+ }
432
+ })
433
+
434
+ // Handle worker consolidation after all workers complete
435
+ event.dispatcher.on(event.workers.result, async result => {
436
+ if (process.env.RUNS_WITH_WORKERS) {
437
+ // Only run consolidation in main process
438
+ await consolidateWorkerJsonResults(options)
439
+ }
440
+ })
441
+
442
+ /**
443
+ * Safely serialize data to JSON, handling circular references
444
+ */
445
+ function safeJsonStringify(data) {
446
+ const seen = new WeakSet()
447
+ return JSON.stringify(
448
+ data,
449
+ (key, value) => {
450
+ if (typeof value === 'object' && value !== null) {
451
+ if (seen.has(value)) {
452
+ // For error objects, try to extract useful information instead of "[Circular Reference]"
453
+ if (key === 'err' || key === 'error') {
454
+ return {
455
+ message: value.message || 'Error occurred',
456
+ stack: value.stack || '',
457
+ name: value.name || 'Error',
458
+ }
459
+ }
460
+ // Skip circular references for other objects
461
+ return undefined
462
+ }
463
+ seen.add(value)
464
+
465
+ // Special handling for error objects to preserve important properties
466
+ if (value instanceof Error || (value.message && value.stack)) {
467
+ return {
468
+ message: value.message || '',
469
+ stack: value.stack || '',
470
+ name: value.name || 'Error',
471
+ toString: () => value.message || 'Error occurred',
472
+ }
473
+ }
474
+ }
475
+ return value
476
+ },
477
+ 2,
478
+ )
479
+ }
480
+
481
+ function generateTestId(test) {
482
+ return crypto
483
+ .createHash('sha256')
484
+ .update(`${test.parent?.title || 'unknown'}_${test.title}`)
485
+ .digest('hex')
486
+ .substring(0, 8)
487
+ }
488
+
489
+ function collectScreenshotsFromFilesystem(test, testId) {
490
+ const screenshots = []
491
+
492
+ try {
493
+ // Common screenshot locations to check
494
+ const possibleDirs = [
495
+ reportDir, // Same as report directory
496
+ global.output_dir || './output', // Global output directory
497
+ path.resolve(global.codecept_dir || '.', 'output'), // Codecept output directory
498
+ path.resolve('.', 'output'), // Current directory output
499
+ path.resolve('.', '_output'), // Alternative output directory
500
+ path.resolve('output'), // Relative output directory
501
+ path.resolve('qa', 'output'), // QA project output directory
502
+ path.resolve('..', 'qa', 'output'), // Parent QA project output directory
503
+ ]
504
+
505
+ // Use the exact same logic as screenshotOnFail plugin's testToFileName function
506
+ const originalTestName = test.title || 'test'
507
+ const originalFeatureName = test.parent?.title || 'feature'
508
+
509
+ // Replicate testToFileName logic from lib/mocha/test.js
510
+ function replicateTestToFileName(testTitle) {
511
+ let fileName = testTitle
512
+
513
+ // Slice to 100 characters first
514
+ fileName = fileName.slice(0, 100)
515
+
516
+ // Handle data-driven tests: remove everything from '{' onwards (with 3 chars before)
517
+ if (fileName.indexOf('{') !== -1) {
518
+ fileName = fileName.substr(0, fileName.indexOf('{') - 3).trim()
519
+ }
520
+
521
+ // Apply clearString logic from utils.js
522
+ if (fileName.endsWith('.')) {
523
+ fileName = fileName.slice(0, -1)
524
+ }
525
+ fileName = fileName
526
+ .replace(/ /g, '_')
527
+ .replace(/"/g, "'")
528
+ .replace(/\//g, '_')
529
+ .replace(/</g, '(')
530
+ .replace(/>/g, ')')
531
+ .replace(/:/g, '_')
532
+ .replace(/\\/g, '_')
533
+ .replace(/\|/g, '_')
534
+ .replace(/\?/g, '.')
535
+ .replace(/\*/g, '^')
536
+ .replace(/'/g, '')
537
+
538
+ // Final slice to 100 characters
539
+ return fileName.slice(0, 100)
540
+ }
541
+
542
+ const testName = replicateTestToFileName(originalTestName)
543
+ const featureName = replicateTestToFileName(originalFeatureName)
544
+
545
+ output.print(`HTML Reporter: Original test title: "${originalTestName}"`)
546
+ output.print(`HTML Reporter: CodeceptJS filename: "${testName}"`)
547
+
548
+ // Generate possible screenshot names based on CodeceptJS patterns
549
+ const possibleNames = [
550
+ `${testName}.failed.png`, // Primary CodeceptJS screenshotOnFail pattern
551
+ `${testName}.failed.jpg`,
552
+ `${featureName}_${testName}.failed.png`,
553
+ `${featureName}_${testName}.failed.jpg`,
554
+ `Test_${testName}.failed.png`, // Alternative pattern
555
+ `Test_${testName}.failed.jpg`,
556
+ `${testName}.png`,
557
+ `${testName}.jpg`,
558
+ `${featureName}_${testName}.png`,
559
+ `${featureName}_${testName}.jpg`,
560
+ `failed_${testName}.png`,
561
+ `failed_${testName}.jpg`,
562
+ `screenshot_${testId}.png`,
563
+ `screenshot_${testId}.jpg`,
564
+ 'screenshot.png',
565
+ 'screenshot.jpg',
566
+ 'failure.png',
567
+ 'failure.jpg',
568
+ ]
569
+
570
+ output.print(`HTML Reporter: Checking ${possibleNames.length} possible screenshot names for "${testName}"`)
571
+
572
+ // Search for screenshots in possible directories
573
+ for (const dir of possibleDirs) {
574
+ output.print(`HTML Reporter: Checking directory: ${dir}`)
575
+ if (!fs.existsSync(dir)) {
576
+ output.print(`HTML Reporter: Directory does not exist: ${dir}`)
577
+ continue
578
+ }
579
+
580
+ try {
581
+ const files = fs.readdirSync(dir)
582
+ output.print(`HTML Reporter: Found ${files.length} files in ${dir}`)
583
+
584
+ // Look for exact matches first
585
+ for (const name of possibleNames) {
586
+ if (files.includes(name)) {
587
+ const fullPath = path.join(dir, name)
588
+ if (!screenshots.includes(fullPath)) {
589
+ screenshots.push(fullPath)
590
+ output.print(`HTML Reporter: Found screenshot: ${fullPath}`)
591
+ }
592
+ }
593
+ }
594
+
595
+ // Look for screenshot files that are specifically for this test
596
+ // Be more strict to avoid cross-test contamination
597
+ const screenshotFiles = files.filter(file => {
598
+ const lowerFile = file.toLowerCase()
599
+ const lowerTestName = testName.toLowerCase()
600
+ const lowerFeatureName = featureName.toLowerCase()
601
+
602
+ return (
603
+ file.match(/\.(png|jpg|jpeg|gif|webp|bmp)$/i) &&
604
+ // Exact test name matches with .failed pattern (most specific)
605
+ (file === `${testName}.failed.png` ||
606
+ file === `${testName}.failed.jpg` ||
607
+ file === `${featureName}_${testName}.failed.png` ||
608
+ file === `${featureName}_${testName}.failed.jpg` ||
609
+ file === `Test_${testName}.failed.png` ||
610
+ file === `Test_${testName}.failed.jpg` ||
611
+ // Word boundary checks for .failed pattern
612
+ (lowerFile.includes('.failed.') &&
613
+ (lowerFile.startsWith(lowerTestName + '.') || lowerFile.startsWith(lowerFeatureName + '_' + lowerTestName + '.') || lowerFile.startsWith('test_' + lowerTestName + '.'))))
614
+ )
615
+ })
616
+
617
+ for (const file of screenshotFiles) {
618
+ const fullPath = path.join(dir, file)
619
+ if (!screenshots.includes(fullPath)) {
620
+ screenshots.push(fullPath)
621
+ output.print(`HTML Reporter: Found related screenshot: ${fullPath}`)
622
+ }
623
+ }
624
+ } catch (error) {
625
+ // Ignore directory read errors
626
+ output.print(`HTML Reporter: Could not read directory ${dir}: ${error.message}`)
627
+ }
628
+ }
629
+ } catch (error) {
630
+ output.print(`HTML Reporter: Error collecting screenshots: ${error.message}`)
631
+ }
632
+
633
+ return screenshots
634
+ }
635
+
636
+ function isBddGherkinTest(test, suite) {
637
+ // Check if the suite has BDD/Gherkin properties
638
+ return !!(suite && (suite.feature || suite.file?.endsWith('.feature')))
639
+ }
640
+
641
+ function getBddFeatureInfo(test, suite) {
642
+ if (!suite) return null
643
+
644
+ return {
645
+ name: suite.feature?.name || suite.title,
646
+ description: suite.feature?.description || suite.comment || '',
647
+ language: suite.feature?.language || 'en',
648
+ tags: suite.tags || [],
649
+ file: suite.file || '',
650
+ }
651
+ }
652
+
653
+ function exportTestStats(data, config) {
654
+ const statsPath = path.resolve(reportDir, config.exportStatsPath)
655
+
656
+ const exportData = {
657
+ timestamp: data.endTime.toISOString(),
658
+ duration: data.duration,
659
+ stats: data.stats,
660
+ retries: data.retries,
661
+ testCount: data.tests.length,
662
+ passedTests: data.tests.filter(t => t.state === 'passed').length,
663
+ failedTests: data.tests.filter(t => t.state === 'failed').length,
664
+ pendingTests: data.tests.filter(t => t.state === 'pending').length,
665
+ tests: data.tests.map(test => ({
666
+ id: test.id,
667
+ title: test.title,
668
+ feature: test.parent?.title || 'Unknown',
669
+ state: test.state,
670
+ duration: test.duration,
671
+ tags: test.tags,
672
+ meta: test.meta,
673
+ retryAttempts: test.retryAttempts,
674
+ uid: test.uid,
675
+ })),
676
+ }
677
+
678
+ try {
679
+ fs.writeFileSync(statsPath, JSON.stringify(exportData, null, 2))
680
+ output.print(`Test stats exported to: ${statsPath}`)
681
+ } catch (error) {
682
+ output.print(`Failed to export test stats: ${error.message}`)
683
+ }
684
+ }
685
+
686
+ function saveTestHistory(data, config) {
687
+ const historyPath = path.resolve(reportDir, config.historyPath)
688
+ let history = []
689
+
690
+ // Load existing history
691
+ try {
692
+ if (fs.existsSync(historyPath)) {
693
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf8'))
694
+ }
695
+ } catch (error) {
696
+ output.print(`Failed to load existing history: ${error.message}`)
697
+ }
698
+
699
+ // Add current run to history
700
+ history.unshift({
701
+ timestamp: data.endTime.toISOString(),
702
+ duration: data.duration,
703
+ stats: data.stats,
704
+ retries: data.retries.length,
705
+ testCount: data.tests.length,
706
+ })
707
+
708
+ // Limit history entries
709
+ if (history.length > config.maxHistoryEntries) {
710
+ history = history.slice(0, config.maxHistoryEntries)
711
+ }
712
+
713
+ try {
714
+ fs.writeFileSync(historyPath, JSON.stringify(history, null, 2))
715
+ output.print(`Test history saved to: ${historyPath}`)
716
+ } catch (error) {
717
+ output.print(`Failed to save test history: ${error.message}`)
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Consolidates JSON reports from multiple workers into a single HTML report
723
+ */
724
+ async function consolidateWorkerJsonResults(config) {
725
+ const jsonFiles = fs.readdirSync(reportDir).filter(file => file.startsWith('worker-') && file.endsWith('-results.json'))
726
+
727
+ if (jsonFiles.length === 0) {
728
+ output.print('HTML Reporter: No worker JSON results found to consolidate')
729
+ return
730
+ }
731
+
732
+ output.print(`HTML Reporter: Found ${jsonFiles.length} worker JSON files to consolidate`)
733
+
734
+ // Initialize consolidated data structure
735
+ const consolidatedData = {
736
+ stats: {
737
+ tests: 0,
738
+ passes: 0,
739
+ failures: 0,
740
+ pending: 0,
741
+ skipped: 0,
742
+ duration: 0,
743
+ failedHooks: 0,
744
+ },
745
+ tests: [],
746
+ failures: [],
747
+ hooks: [],
748
+ startTime: new Date(),
749
+ endTime: new Date(),
750
+ retries: [],
751
+ duration: 0,
752
+ }
753
+
754
+ try {
755
+ // Process each worker's JSON file
756
+ for (const jsonFile of jsonFiles) {
757
+ const jsonPath = path.join(reportDir, jsonFile)
758
+ try {
759
+ const workerData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
760
+
761
+ // Merge stats
762
+ if (workerData.stats) {
763
+ consolidatedData.stats.passes += workerData.stats.passes || 0
764
+ consolidatedData.stats.failures += workerData.stats.failures || 0
765
+ consolidatedData.stats.tests += workerData.stats.tests || 0
766
+ consolidatedData.stats.pending += workerData.stats.pending || 0
767
+ consolidatedData.stats.skipped += workerData.stats.skipped || 0
768
+ consolidatedData.stats.duration += workerData.stats.duration || 0
769
+ consolidatedData.stats.failedHooks += workerData.stats.failedHooks || 0
770
+ }
771
+
772
+ // Merge tests and failures
773
+ if (workerData.tests) consolidatedData.tests.push(...workerData.tests)
774
+ if (workerData.failures) consolidatedData.failures.push(...workerData.failures)
775
+ if (workerData.hooks) consolidatedData.hooks.push(...workerData.hooks)
776
+ if (workerData.retries) consolidatedData.retries.push(...workerData.retries)
777
+
778
+ // Update timestamps
779
+ if (workerData.startTime) {
780
+ const workerStart = new Date(workerData.startTime).getTime()
781
+ const currentStart = new Date(consolidatedData.startTime).getTime()
782
+ if (workerStart < currentStart) {
783
+ consolidatedData.startTime = workerData.startTime
784
+ }
785
+ }
786
+
787
+ if (workerData.endTime) {
788
+ const workerEnd = new Date(workerData.endTime).getTime()
789
+ const currentEnd = new Date(consolidatedData.endTime).getTime()
790
+ if (workerEnd > currentEnd) {
791
+ consolidatedData.endTime = workerData.endTime
792
+ }
793
+ }
794
+
795
+ // Update duration
796
+ if (workerData.duration) {
797
+ consolidatedData.duration = Math.max(consolidatedData.duration, workerData.duration)
798
+ }
799
+
800
+ // Clean up the worker JSON file
801
+ try {
802
+ fs.unlinkSync(jsonPath)
803
+ } catch (error) {
804
+ output.print(`Failed to delete worker JSON file ${jsonFile}: ${error.message}`)
805
+ }
806
+ } catch (error) {
807
+ output.print(`Failed to process worker JSON file ${jsonFile}: ${error.message}`)
808
+ }
809
+ }
810
+
811
+ // Generate the final HTML report
812
+ generateHtmlReport(consolidatedData, config)
813
+
814
+ // Export stats if configured
815
+ if (config.exportStats) {
816
+ exportTestStats(consolidatedData, config)
817
+ }
818
+
819
+ // Save history if configured
820
+ if (config.keepHistory) {
821
+ saveTestHistory(consolidatedData, config)
822
+ }
823
+
824
+ output.print(`HTML Reporter: Successfully consolidated ${jsonFiles.length} worker reports`)
825
+ } catch (error) {
826
+ output.print(`HTML Reporter: Failed to consolidate worker reports: ${error.message}`)
827
+ }
828
+ }
829
+
830
+ async function generateHtmlReport(data, config) {
831
+ const reportPath = path.join(reportDir, config.reportFileName)
832
+
833
+ // Load history if available
834
+ let history = []
835
+ if (config.keepHistory) {
836
+ const historyPath = path.resolve(reportDir, config.historyPath)
837
+ try {
838
+ if (fs.existsSync(historyPath)) {
839
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf8')) // Show all available history
840
+ }
841
+ } catch (error) {
842
+ output.print(`Failed to load history for report: ${error.message}`)
843
+ }
844
+
845
+ // Add current run to history for chart display (before saving to file)
846
+ const currentRun = {
847
+ timestamp: data.endTime.toISOString(),
848
+ duration: data.duration,
849
+ stats: data.stats,
850
+ retries: data.retries.length,
851
+ testCount: data.tests.length,
852
+ }
853
+ history.unshift(currentRun)
854
+
855
+ // Limit history entries for chart display
856
+ if (history.length > config.maxHistoryEntries) {
857
+ history = history.slice(0, config.maxHistoryEntries)
858
+ }
859
+ }
860
+
861
+ // Get system information
862
+ const systemInfo = await getMachineInfo()
863
+
864
+ const html = template(getHtmlTemplate(), {
865
+ title: `CodeceptJS Test Report v${Codecept.version()}`,
866
+ timestamp: data.endTime.toISOString(),
867
+ duration: formatDuration(data.duration),
868
+ stats: JSON.stringify(data.stats),
869
+ history: JSON.stringify(history),
870
+ statsHtml: generateStatsHtml(data.stats),
871
+ testsHtml: generateTestsHtml(data.tests, config),
872
+ retriesHtml: config.showRetries ? generateRetriesHtml(data.retries) : '',
873
+ cssStyles: getCssStyles(),
874
+ jsScripts: getJsScripts(),
875
+ showRetries: config.showRetries ? 'block' : 'none',
876
+ showHistory: config.keepHistory && history.length > 0 ? 'block' : 'none',
877
+ codeceptVersion: Codecept.version(),
878
+ systemInfoHtml: generateSystemInfoHtml(systemInfo),
879
+ })
880
+
881
+ fs.writeFileSync(reportPath, html)
882
+ output.print(`HTML Report saved to: ${reportPath}`)
883
+ }
884
+
885
+ function generateStatsHtml(stats) {
886
+ const passed = stats.passes || 0
887
+ const failed = stats.failures || 0
888
+ const pending = stats.pending || 0
889
+ const total = stats.tests || 0
890
+
891
+ return `
892
+ <div class="stats-cards">
893
+ <div class="stat-card total">
894
+ <h3>Total</h3>
895
+ <span class="stat-number">${total}</span>
896
+ </div>
897
+ <div class="stat-card passed">
898
+ <h3>Passed</h3>
899
+ <span class="stat-number">${passed}</span>
900
+ </div>
901
+ <div class="stat-card failed">
902
+ <h3>Failed</h3>
903
+ <span class="stat-number">${failed}</span>
904
+ </div>
905
+ <div class="stat-card pending">
906
+ <h3>Pending</h3>
907
+ <span class="stat-number">${pending}</span>
908
+ </div>
909
+ </div>
910
+ <div class="pie-chart-container">
911
+ <canvas id="statsChart" width="300" height="300"></canvas>
912
+ <script>
913
+ // Pie chart data will be rendered by JavaScript
914
+ window.chartData = {
915
+ passed: ${passed},
916
+ failed: ${failed},
917
+ pending: ${pending}
918
+ };
919
+ </script>
920
+ </div>
921
+ `
922
+ }
923
+
924
+ function generateTestsHtml(tests, config) {
925
+ if (!tests || tests.length === 0) {
926
+ return '<p>No tests found.</p>'
927
+ }
928
+
929
+ return tests
930
+ .map(test => {
931
+ const statusClass = test.state || 'unknown'
932
+ const feature = test.isBdd && test.feature ? test.feature.name : test.parent?.title || 'Unknown Feature'
933
+ const steps = config.showSteps && test.steps ? (test.isBdd ? generateBddStepsHtml(test.steps) : generateStepsHtml(test.steps)) : ''
934
+ const featureDetails = test.isBdd && test.feature ? generateBddFeatureHtml(test.feature) : ''
935
+ const hooks = test.hooks && test.hooks.length > 0 ? generateHooksHtml(test.hooks) : ''
936
+ const artifacts = config.includeArtifacts && test.artifacts ? generateArtifactsHtml(test.artifacts, test.state === 'failed') : ''
937
+ const metadata = config.showMetadata && (test.meta || test.opts) ? generateMetadataHtml(test.meta, test.opts) : ''
938
+ const tags = config.showTags && test.tags && test.tags.length > 0 ? generateTagsHtml(test.tags) : ''
939
+ const retries = config.showRetries && test.retryAttempts > 0 ? generateTestRetryHtml(test.retryAttempts) : ''
940
+ const notes = test.notes && test.notes.length > 0 ? generateNotesHtml(test.notes) : ''
941
+
942
+ return `
943
+ <div class="test-item ${statusClass}${test.isBdd ? ' bdd-test' : ''}" id="test-${test.id}" data-feature="${escapeHtml(feature)}" data-status="${statusClass}" data-tags="${(test.tags || []).join(',')}" data-retries="${test.retryAttempts || 0}" data-type="${test.isBdd ? 'bdd' : 'regular'}">
944
+ <div class="test-header" onclick="toggleTestDetails('test-${test.id}')">
945
+ <span class="test-status ${statusClass}">●</span>
946
+ <div class="test-info">
947
+ <h3 class="test-title">${test.isBdd ? `Scenario: ${test.title}` : test.title}</h3>
948
+ <div class="test-meta-line">
949
+ <span class="test-feature">${test.isBdd ? 'Feature: ' : ''}${feature}</span>
950
+ ${test.uid ? `<span class="test-uid">${test.uid}</span>` : ''}
951
+ <span class="test-duration">${formatDuration(test.duration)}</span>
952
+ ${test.retryAttempts > 0 ? `<span class="retry-badge">${test.retryAttempts} retries</span>` : ''}
953
+ ${test.isBdd ? '<span class="bdd-badge">Gherkin</span>' : ''}
954
+ </div>
955
+ </div>
956
+ </div>
957
+ <div class="test-details" id="details-test-${test.id}">
958
+ ${test.err ? `<div class="error-message"><pre>${escapeHtml(getErrorMessage(test))}</pre></div>` : ''}
959
+ ${featureDetails}
960
+ ${tags}
961
+ ${metadata}
962
+ ${retries}
963
+ ${notes}
964
+ ${hooks}
965
+ ${steps}
966
+ ${artifacts}
967
+ </div>
968
+ </div>
969
+ `
970
+ })
971
+ .join('')
972
+ }
973
+
974
+ function generateStepsHtml(steps) {
975
+ if (!steps || steps.length === 0) return ''
976
+
977
+ const stepsHtml = steps
978
+ .map(step => {
979
+ const statusClass = step.status || 'unknown'
980
+ const args = step.args ? step.args.map(arg => JSON.stringify(arg)).join(', ') : ''
981
+ const stepName = step.name || 'unknown step'
982
+ const actor = step.actor || 'I'
983
+
984
+ return `
985
+ <div class="step-item ${statusClass}">
986
+ <span class="step-status ${statusClass}">●</span>
987
+ <span class="step-title">${actor}.${stepName}(${args})</span>
988
+ <span class="step-duration">${formatDuration(step.duration)}</span>
989
+ </div>
990
+ `
991
+ })
992
+ .join('')
993
+
994
+ return `
995
+ <div class="steps-section">
996
+ <h4>Steps:</h4>
997
+ <div class="steps-list">${stepsHtml}</div>
998
+ </div>
999
+ `
1000
+ }
1001
+
1002
+ function generateBddStepsHtml(steps) {
1003
+ if (!steps || steps.length === 0) return ''
1004
+
1005
+ const stepsHtml = steps
1006
+ .map(step => {
1007
+ const statusClass = step.status || 'unknown'
1008
+ const keyword = step.keyword || 'Given'
1009
+ const text = step.text || ''
1010
+ const comment = step.comment ? `<div class="step-comment">${escapeHtml(step.comment)}</div>` : ''
1011
+
1012
+ return `
1013
+ <div class="bdd-step-item ${statusClass}">
1014
+ <span class="step-status ${statusClass}">●</span>
1015
+ <span class="bdd-keyword">${keyword}</span>
1016
+ <span class="bdd-step-text">${escapeHtml(text)}</span>
1017
+ <span class="step-duration">${formatDuration(step.duration)}</span>
1018
+ ${comment}
1019
+ </div>
1020
+ `
1021
+ })
1022
+ .join('')
1023
+
1024
+ return `
1025
+ <div class="bdd-steps-section">
1026
+ <h4>Scenario Steps:</h4>
1027
+ <div class="bdd-steps-list">${stepsHtml}</div>
1028
+ </div>
1029
+ `
1030
+ }
1031
+
1032
+ function generateBddFeatureHtml(feature) {
1033
+ if (!feature) return ''
1034
+
1035
+ const description = feature.description ? `<div class="feature-description">${escapeHtml(feature.description)}</div>` : ''
1036
+ 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>` : ''
1037
+
1038
+ return `
1039
+ <div class="bdd-feature-section">
1040
+ <h4>Feature Information:</h4>
1041
+ <div class="feature-info">
1042
+ <div class="feature-name">Feature: ${escapeHtml(feature.name)}</div>
1043
+ ${description}
1044
+ ${featureTags}
1045
+ ${feature.file ? `<div class="feature-file">File: ${escapeHtml(feature.file)}</div>` : ''}
1046
+ </div>
1047
+ </div>
1048
+ `
1049
+ }
1050
+
1051
+ function generateHooksHtml(hooks) {
1052
+ if (!hooks || hooks.length === 0) return ''
1053
+
1054
+ const hooksHtml = hooks
1055
+ .map(hook => {
1056
+ const statusClass = hook.status || 'unknown'
1057
+ const hookType = hook.type || 'hook'
1058
+ const hookTitle = hook.title || `${hookType} hook`
1059
+
1060
+ return `
1061
+ <div class="hook-item ${statusClass}">
1062
+ <span class="hook-status ${statusClass}">●</span>
1063
+ <span class="hook-title">${hookType}: ${hookTitle}</span>
1064
+ <span class="hook-duration">${formatDuration(hook.duration)}</span>
1065
+ ${hook.error ? `<div class="hook-error">${escapeHtml(hook.error)}</div>` : ''}
1066
+ </div>
1067
+ `
1068
+ })
1069
+ .join('')
1070
+
1071
+ return `
1072
+ <div class="hooks-section">
1073
+ <h4>Hooks:</h4>
1074
+ <div class="hooks-list">${hooksHtml}</div>
1075
+ </div>
1076
+ `
1077
+ }
1078
+
1079
+ function generateMetadataHtml(meta, opts) {
1080
+ const allMeta = { ...(opts || {}), ...(meta || {}) }
1081
+ if (!allMeta || Object.keys(allMeta).length === 0) return ''
1082
+
1083
+ const metaHtml = Object.entries(allMeta)
1084
+ .filter(([key, value]) => value !== undefined && value !== null)
1085
+ .map(([key, value]) => {
1086
+ const displayValue = typeof value === 'object' ? JSON.stringify(value) : value.toString()
1087
+ return `<div class="meta-item"><span class="meta-key">${escapeHtml(key)}:</span> <span class="meta-value">${escapeHtml(displayValue)}</span></div>`
1088
+ })
1089
+ .join('')
1090
+
1091
+ return `
1092
+ <div class="metadata-section">
1093
+ <h4>Metadata:</h4>
1094
+ <div class="metadata-list">${metaHtml}</div>
1095
+ </div>
1096
+ `
1097
+ }
1098
+
1099
+ function generateTagsHtml(tags) {
1100
+ if (!tags || tags.length === 0) return ''
1101
+
1102
+ const tagsHtml = tags.map(tag => `<span class="test-tag">${escapeHtml(tag)}</span>`).join('')
1103
+
1104
+ return `
1105
+ <div class="tags-section">
1106
+ <h4>Tags:</h4>
1107
+ <div class="tags-list">${tagsHtml}</div>
1108
+ </div>
1109
+ `
1110
+ }
1111
+
1112
+ function generateNotesHtml(notes) {
1113
+ if (!notes || notes.length === 0) return ''
1114
+
1115
+ 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('')
1116
+
1117
+ return `
1118
+ <div class="notes-section">
1119
+ <h4>Notes:</h4>
1120
+ <div class="notes-list">${notesHtml}</div>
1121
+ </div>
1122
+ `
1123
+ }
1124
+
1125
+ function generateTestRetryHtml(retryAttempts) {
1126
+ return `
1127
+ <div class="retry-section">
1128
+ <h4>Retry Information:</h4>
1129
+ <div class="retry-info">
1130
+ <span class="retry-count">Total retry attempts: <strong>${retryAttempts}</strong></span>
1131
+ </div>
1132
+ </div>
1133
+ `
1134
+ }
1135
+
1136
+ function generateArtifactsHtml(artifacts, isFailedTest = false) {
1137
+ if (!artifacts || artifacts.length === 0) {
1138
+ output.print(`HTML Reporter: No artifacts found for test`)
1139
+ return ''
1140
+ }
1141
+
1142
+ output.print(`HTML Reporter: Processing ${artifacts.length} artifacts, isFailedTest: ${isFailedTest}`)
1143
+ output.print(`HTML Reporter: Artifacts: ${JSON.stringify(artifacts)}`)
1144
+
1145
+ // Separate screenshots from other artifacts
1146
+ const screenshots = []
1147
+ const otherArtifacts = []
1148
+
1149
+ artifacts.forEach(artifact => {
1150
+ output.print(`HTML Reporter: Processing artifact: ${artifact} (type: ${typeof artifact})`)
1151
+
1152
+ // Handle different artifact formats
1153
+ let artifactPath = artifact
1154
+ if (typeof artifact === 'object' && artifact.path) {
1155
+ artifactPath = artifact.path
1156
+ } else if (typeof artifact === 'object' && artifact.file) {
1157
+ artifactPath = artifact.file
1158
+ } else if (typeof artifact === 'object' && artifact.src) {
1159
+ artifactPath = artifact.src
1160
+ }
1161
+
1162
+ // Check if it's a screenshot file
1163
+ if (typeof artifactPath === 'string' && artifactPath.match(/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i)) {
1164
+ screenshots.push(artifactPath)
1165
+ output.print(`HTML Reporter: Found screenshot: ${artifactPath}`)
1166
+ } else {
1167
+ otherArtifacts.push(artifact)
1168
+ output.print(`HTML Reporter: Found other artifact: ${artifact}`)
1169
+ }
1170
+ })
1171
+
1172
+ output.print(`HTML Reporter: Found ${screenshots.length} screenshots and ${otherArtifacts.length} other artifacts`)
1173
+
1174
+ let artifactsHtml = ''
1175
+
1176
+ // For failed tests, prominently display screenshots
1177
+ if (isFailedTest && screenshots.length > 0) {
1178
+ const screenshotsHtml = screenshots
1179
+ .map(screenshot => {
1180
+ let relativePath = path.relative(reportDir, screenshot)
1181
+ const filename = path.basename(screenshot)
1182
+
1183
+ // If relative path goes up directories, try to find the file in common locations
1184
+ if (relativePath.startsWith('..')) {
1185
+ // Try to find screenshot relative to output directory
1186
+ const outputRelativePath = path.relative(reportDir, path.resolve(screenshot))
1187
+ if (!outputRelativePath.startsWith('..')) {
1188
+ relativePath = outputRelativePath
1189
+ } else {
1190
+ // Use just the filename if file is in same directory as report
1191
+ const sameDir = path.join(reportDir, filename)
1192
+ if (fs.existsSync(sameDir)) {
1193
+ relativePath = filename
1194
+ } else {
1195
+ // Keep original path as fallback
1196
+ relativePath = screenshot
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1202
+
1203
+ return `
1204
+ <div class="screenshot-container">
1205
+ <div class="screenshot-header">
1206
+ <span class="screenshot-label">📸 ${escapeHtml(filename)}</span>
1207
+ </div>
1208
+ <img src="${relativePath}" alt="Test failure screenshot" class="failure-screenshot" onclick="openImageModal(this.src)"/>
1209
+ </div>
1210
+ `
1211
+ })
1212
+ .join('')
1213
+
1214
+ artifactsHtml += `
1215
+ <div class="screenshots-section">
1216
+ <h4>Screenshots:</h4>
1217
+ <div class="screenshots-list">${screenshotsHtml}</div>
1218
+ </div>
1219
+ `
1220
+ } else if (screenshots.length > 0) {
1221
+ // For non-failed tests, display screenshots normally
1222
+ const screenshotsHtml = screenshots
1223
+ .map(screenshot => {
1224
+ let relativePath = path.relative(reportDir, screenshot)
1225
+ const filename = path.basename(screenshot)
1226
+
1227
+ // If relative path goes up directories, try to find the file in common locations
1228
+ if (relativePath.startsWith('..')) {
1229
+ // Try to find screenshot relative to output directory
1230
+ const outputRelativePath = path.relative(reportDir, path.resolve(screenshot))
1231
+ if (!outputRelativePath.startsWith('..')) {
1232
+ relativePath = outputRelativePath
1233
+ } else {
1234
+ // Use just the filename if file is in same directory as report
1235
+ const sameDir = path.join(reportDir, filename)
1236
+ if (fs.existsSync(sameDir)) {
1237
+ relativePath = filename
1238
+ } else {
1239
+ // Keep original path as fallback
1240
+ relativePath = screenshot
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ output.print(`HTML Reporter: Screenshot ${filename} -> ${relativePath}`)
1246
+ return `<img src="${relativePath}" alt="Screenshot" class="artifact-image" onclick="openImageModal(this.src)"/>`
1247
+ })
1248
+ .join('')
1249
+
1250
+ artifactsHtml += `
1251
+ <div class="screenshots-section">
1252
+ <h4>Screenshots:</h4>
1253
+ <div class="screenshots-list">${screenshotsHtml}</div>
1254
+ </div>
1255
+ `
1256
+ }
1257
+
1258
+ // Display other artifacts if any
1259
+ if (otherArtifacts.length > 0) {
1260
+ const otherArtifactsHtml = otherArtifacts.map(artifact => `<div class="artifact-item">${escapeHtml(artifact.toString())}</div>`).join('')
1261
+
1262
+ artifactsHtml += `
1263
+ <div class="other-artifacts-section">
1264
+ <h4>Other Artifacts:</h4>
1265
+ <div class="artifacts-list">${otherArtifactsHtml}</div>
1266
+ </div>
1267
+ `
1268
+ }
1269
+
1270
+ return artifactsHtml
1271
+ ? `
1272
+ <div class="artifacts-section">
1273
+ ${artifactsHtml}
1274
+ </div>
1275
+ `
1276
+ : ''
1277
+ }
1278
+
1279
+ function generateFailuresHtml(failures) {
1280
+ if (!failures || failures.length === 0) {
1281
+ return '<p>No failures.</p>'
1282
+ }
1283
+
1284
+ return failures
1285
+ .map((failure, index) => {
1286
+ // Helper function to safely extract string values
1287
+ const safeString = value => {
1288
+ if (!value) return ''
1289
+ if (typeof value === 'string') return value
1290
+ if (typeof value === 'object' && value.toString) {
1291
+ const str = value.toString()
1292
+ return str === '[object Object]' ? '' : str
1293
+ }
1294
+ return String(value)
1295
+ }
1296
+
1297
+ if (typeof failure === 'object' && failure !== null) {
1298
+ // Enhanced failure object with test details
1299
+ console.log('this is failure', failure)
1300
+ const testName = safeString(failure.testName) || 'Unknown Test'
1301
+ const featureName = safeString(failure.featureName) || 'Unknown Feature'
1302
+ let message = safeString(failure.message) || 'Test failed'
1303
+ const stack = safeString(failure.stack) || ''
1304
+ const filePath = safeString(failure.filePath) || ''
1305
+
1306
+ // If message is still "[object Object]", try to extract from the failure object itself
1307
+ if (message === '[object Object]' || message === '') {
1308
+ if (failure.err && failure.err.message) {
1309
+ message = safeString(failure.err.message)
1310
+ } else if (failure.error && failure.error.message) {
1311
+ message = safeString(failure.error.message)
1312
+ } else if (failure.toString && typeof failure.toString === 'function') {
1313
+ const str = failure.toString()
1314
+ message = str === '[object Object]' ? 'Test failed' : str
1315
+ } else {
1316
+ message = 'Test failed'
1317
+ }
1318
+ }
1319
+
1320
+ return `
1321
+ <div class="failure-item">
1322
+ <h4>Failure ${index + 1}: ${escapeHtml(testName)}</h4>
1323
+ <div class="failure-meta">
1324
+ <span class="failure-feature">Feature: ${escapeHtml(featureName)}</span>
1325
+ ${filePath ? `<span class="failure-file">File: <a href="file://${filePath}" target="_blank">${escapeHtml(filePath)}</a></span>` : ''}
1326
+ </div>
1327
+ <div class="failure-message">
1328
+ <strong>Error:</strong> ${escapeHtml(message)}
1329
+ </div>
1330
+ ${stack ? `<pre class="failure-stack">${escapeHtml(stack.replace(/\x1b\[[0-9;]*m/g, ''))}</pre>` : ''}
1331
+ </div>
1332
+ `
1333
+ } else {
1334
+ // Fallback for simple string failures
1335
+ const failureText = safeString(failure).replace(/\x1b\[[0-9;]*m/g, '') || 'Test failed'
1336
+ return `
1337
+ <div class="failure-item">
1338
+ <h4>Failure ${index + 1}</h4>
1339
+ <pre class="failure-details">${escapeHtml(failureText)}</pre>
1340
+ </div>
1341
+ `
1342
+ }
1343
+ })
1344
+ .join('')
1345
+ }
1346
+
1347
+ function generateRetriesHtml(retries) {
1348
+ if (!retries || retries.length === 0) {
1349
+ return '<p>No retried tests.</p>'
1350
+ }
1351
+
1352
+ return retries
1353
+ .map(
1354
+ retry => `
1355
+ <div class="retry-item">
1356
+ <h4>${retry.testTitle}</h4>
1357
+ <div class="retry-details">
1358
+ <span>Attempts: <strong>${retry.attempts}</strong></span>
1359
+ <span>Final State: <span class="status-badge ${retry.finalState}">${retry.finalState}</span></span>
1360
+ <span>Duration: ${formatDuration(retry.duration)}</span>
1361
+ </div>
1362
+ </div>
1363
+ `,
1364
+ )
1365
+ .join('')
1366
+ }
1367
+
1368
+ function formatDuration(duration) {
1369
+ if (!duration) return '0ms'
1370
+ if (duration < 1000) return `${duration}ms`
1371
+ return `${(duration / 1000).toFixed(2)}s`
1372
+ }
1373
+
1374
+ function escapeHtml(text) {
1375
+ if (!text) return ''
1376
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
1377
+ }
1378
+
1379
+ function getErrorMessage(test) {
1380
+ if (!test) return 'Test failed'
1381
+
1382
+ // Helper function to safely extract string from potentially circular objects
1383
+ const safeExtract = (obj, prop) => {
1384
+ try {
1385
+ if (!obj || typeof obj !== 'object') return ''
1386
+ const value = obj[prop]
1387
+ if (typeof value === 'string') return value
1388
+ if (value && typeof value.toString === 'function') {
1389
+ const str = value.toString()
1390
+ return str === '[object Object]' ? '' : str
1391
+ }
1392
+ return ''
1393
+ } catch (e) {
1394
+ return ''
1395
+ }
1396
+ }
1397
+
1398
+ // Helper function to safely stringify objects avoiding circular references
1399
+ const safeStringify = obj => {
1400
+ try {
1401
+ if (!obj) return ''
1402
+ if (typeof obj === 'string') return obj
1403
+
1404
+ // Try to get message property first
1405
+ if (obj.message && typeof obj.message === 'string') {
1406
+ return obj.message
1407
+ }
1408
+
1409
+ // For error objects, extract key properties manually
1410
+ if (obj instanceof Error || (obj.name && obj.message)) {
1411
+ return obj.message || obj.toString() || 'Error occurred'
1412
+ }
1413
+
1414
+ // For other objects, try toString first
1415
+ if (obj.toString && typeof obj.toString === 'function') {
1416
+ const str = obj.toString()
1417
+ if (str !== '[object Object]' && !str.includes('[Circular Reference]')) {
1418
+ return str
1419
+ }
1420
+ }
1421
+
1422
+ // Last resort: extract message-like properties
1423
+ if (obj.message) return obj.message
1424
+ if (obj.description) return obj.description
1425
+ if (obj.text) return obj.text
1426
+
1427
+ return 'Error occurred'
1428
+ } catch (e) {
1429
+ return 'Error occurred'
1430
+ }
1431
+ }
1432
+
1433
+ let errorMessage = ''
1434
+ let errorStack = ''
1435
+
1436
+ // Primary error source
1437
+ if (test.err) {
1438
+ errorMessage = safeExtract(test.err, 'message') || safeStringify(test.err)
1439
+ errorStack = safeExtract(test.err, 'stack')
1440
+ }
1441
+
1442
+ // Alternative error sources for different test frameworks
1443
+ if (!errorMessage && test.error) {
1444
+ errorMessage = safeExtract(test.error, 'message') || safeStringify(test.error)
1445
+ errorStack = safeExtract(test.error, 'stack')
1446
+ }
1447
+
1448
+ // Check for nested error in parent
1449
+ if (!errorMessage && test.parent && test.parent.err) {
1450
+ errorMessage = safeExtract(test.parent.err, 'message') || safeStringify(test.parent.err)
1451
+ errorStack = safeExtract(test.parent.err, 'stack')
1452
+ }
1453
+
1454
+ // Check for error details array (some frameworks use this)
1455
+ if (!errorMessage && test.err && test.err.details && Array.isArray(test.err.details)) {
1456
+ errorMessage = test.err.details
1457
+ .map(item => safeExtract(item, 'message') || safeStringify(item))
1458
+ .filter(msg => msg && msg !== '[Circular]')
1459
+ .join(' ')
1460
+ }
1461
+
1462
+ // Fallback to test title if no error message found
1463
+ if (!errorMessage || errorMessage === '[Circular]') {
1464
+ errorMessage = `Test failed: ${test.title || 'Unknown test'}`
1465
+ }
1466
+
1467
+ // Clean ANSI escape codes and remove circular reference markers
1468
+ const cleanMessage = (errorMessage || '')
1469
+ .replace(/\x1b\[[0-9;]*m/g, '')
1470
+ .replace(/\[Circular\]/g, '')
1471
+ .replace(/\s+/g, ' ')
1472
+ .trim()
1473
+
1474
+ const cleanStack = (errorStack || '')
1475
+ .replace(/\x1b\[[0-9;]*m/g, '')
1476
+ .replace(/\[Circular\]/g, '')
1477
+ .trim()
1478
+
1479
+ // Return combined error information
1480
+ if (cleanStack && cleanStack !== cleanMessage && !cleanMessage.includes(cleanStack)) {
1481
+ return `${cleanMessage}\n\nStack trace:\n${cleanStack}`
1482
+ }
1483
+
1484
+ return cleanMessage
1485
+ }
1486
+
1487
+ function generateSystemInfoHtml(systemInfo) {
1488
+ if (!systemInfo) return ''
1489
+
1490
+ const formatInfo = (key, value) => {
1491
+ if (Array.isArray(value) && value.length > 1) {
1492
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value[1])}</span></div>`
1493
+ } else if (typeof value === 'string' && value !== 'N/A' && value !== 'undefined') {
1494
+ return `<div class="info-item"><span class="info-key">${key}:</span> <span class="info-value">${escapeHtml(value)}</span></div>`
1495
+ }
1496
+ return ''
1497
+ }
1498
+
1499
+ const infoItems = [
1500
+ formatInfo('Node.js', systemInfo.nodeInfo),
1501
+ formatInfo('OS', systemInfo.osInfo),
1502
+ formatInfo('CPU', systemInfo.cpuInfo),
1503
+ formatInfo('Chrome', systemInfo.chromeInfo),
1504
+ formatInfo('Edge', systemInfo.edgeInfo),
1505
+ formatInfo('Firefox', systemInfo.firefoxInfo),
1506
+ formatInfo('Safari', systemInfo.safariInfo),
1507
+ formatInfo('Playwright Browsers', systemInfo.playwrightBrowsers),
1508
+ ]
1509
+ .filter(item => item)
1510
+ .join('')
1511
+
1512
+ if (!infoItems) return ''
1513
+
1514
+ return `
1515
+ <section class="system-info-section">
1516
+ <div class="system-info-header" onclick="toggleSystemInfo()">
1517
+ <h3>Environment Information</h3>
1518
+ <span class="toggle-icon">▼</span>
1519
+ </div>
1520
+ <div class="system-info-content" id="systemInfoContent">
1521
+ <div class="system-info-grid">
1522
+ ${infoItems}
1523
+ </div>
1524
+ </div>
1525
+ </section>
1526
+ `
1527
+ }
1528
+
1529
+ function getHtmlTemplate() {
1530
+ return `
1531
+ <!DOCTYPE html>
1532
+ <html lang="en">
1533
+ <head>
1534
+ <meta charset="UTF-8">
1535
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1536
+ <title>{{title}}</title>
1537
+ <style>{{cssStyles}}</style>
1538
+ </head>
1539
+ <body>
1540
+ <header class="report-header">
1541
+ <h1>{{title}}</h1>
1542
+ <div class="report-meta">
1543
+ <span>Generated: {{timestamp}}</span>
1544
+ <span>Duration: {{duration}}</span>
1545
+ </div>
1546
+ </header>
1547
+
1548
+ <main class="report-content">
1549
+ {{systemInfoHtml}}
1550
+
1551
+ <section class="stats-section">
1552
+ <h2>Test Statistics</h2>
1553
+ {{statsHtml}}
1554
+ </section>
1555
+
1556
+ <section class="history-section" style="display: {{showHistory}};">
1557
+ <h2>Test History</h2>
1558
+ <div class="history-chart-container">
1559
+ <canvas id="historyChart" width="800" height="300"></canvas>
1560
+ </div>
1561
+ </section>
1562
+
1563
+ <section class="filters-section">
1564
+ <h2>Filters</h2>
1565
+ <div class="filter-controls">
1566
+ <div class="filter-group">
1567
+ <label>Status:</label>
1568
+ <select id="statusFilter" multiple>
1569
+ <option value="passed">Passed</option>
1570
+ <option value="failed">Failed</option>
1571
+ <option value="pending">Pending</option>
1572
+ <option value="skipped">Skipped</option>
1573
+ </select>
1574
+ </div>
1575
+ <div class="filter-group">
1576
+ <label>Feature:</label>
1577
+ <input type="text" id="featureFilter" placeholder="Filter by feature...">
1578
+ </div>
1579
+ <div class="filter-group">
1580
+ <label>Tags:</label>
1581
+ <input type="text" id="tagFilter" placeholder="Filter by tags...">
1582
+ </div>
1583
+ <div class="filter-group">
1584
+ <label>Retries:</label>
1585
+ <select id="retryFilter">
1586
+ <option value="all">All</option>
1587
+ <option value="retried">With Retries</option>
1588
+ <option value="no-retries">No Retries</option>
1589
+ </select>
1590
+ </div>
1591
+ <div class="filter-group">
1592
+ <label>Test Type:</label>
1593
+ <select id="typeFilter">
1594
+ <option value="all">All</option>
1595
+ <option value="bdd">BDD/Gherkin</option>
1596
+ <option value="regular">Regular</option>
1597
+ </select>
1598
+ </div>
1599
+ <button onclick="resetFilters()">Reset Filters</button>
1600
+ </div>
1601
+ </section>
1602
+
1603
+ <section class="tests-section">
1604
+ <h2>Test Results</h2>
1605
+ <div class="tests-container">
1606
+ {{testsHtml}}
1607
+ </div>
1608
+ </section>
1609
+
1610
+ <section class="retries-section" style="display: {{showRetries}};">
1611
+ <h2>Test Retries</h2>
1612
+ <div class="retries-container">
1613
+ {{retriesHtml}}
1614
+ </div>
1615
+ </section>
1616
+
1617
+ </main>
1618
+
1619
+ <!-- Modal for images -->
1620
+ <div id="imageModal" class="modal" onclick="closeImageModal()">
1621
+ <img id="modalImage" src="" alt="Enlarged screenshot"/>
1622
+ </div>
1623
+
1624
+ <script>
1625
+ window.testData = {
1626
+ stats: {{stats}},
1627
+ history: {{history}}
1628
+ };
1629
+ </script>
1630
+ <script>{{jsScripts}}</script>
1631
+ </body>
1632
+ </html>
1633
+ `
1634
+ }
1635
+
1636
+ function getCssStyles() {
1637
+ return `
1638
+ * {
1639
+ margin: 0;
1640
+ padding: 0;
1641
+ box-sizing: border-box;
1642
+ }
1643
+
1644
+ body {
1645
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1646
+ line-height: 1.6;
1647
+ color: #333;
1648
+ background-color: #f5f5f5;
1649
+ }
1650
+
1651
+ .report-header {
1652
+ background: #2c3e50;
1653
+ color: white;
1654
+ padding: 2rem 1rem;
1655
+ text-align: center;
1656
+ }
1657
+
1658
+ .report-header h1 {
1659
+ margin-bottom: 0.5rem;
1660
+ font-size: 2.5rem;
1661
+ }
1662
+
1663
+ .report-meta {
1664
+ font-size: 0.9rem;
1665
+ opacity: 0.8;
1666
+ }
1667
+
1668
+ .report-meta span {
1669
+ margin: 0 1rem;
1670
+ }
1671
+
1672
+ .report-content {
1673
+ max-width: 1200px;
1674
+ margin: 2rem auto;
1675
+ padding: 0 1rem;
1676
+ }
1677
+
1678
+ .stats-section, .tests-section, .retries-section, .filters-section, .history-section, .system-info-section {
1679
+ background: white;
1680
+ margin-bottom: 2rem;
1681
+ border-radius: 8px;
1682
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1683
+ overflow: hidden;
1684
+ }
1685
+
1686
+ .stats-section h2, .tests-section h2, .retries-section h2, .filters-section h2, .history-section h2 {
1687
+ background: #34495e;
1688
+ color: white;
1689
+ padding: 1rem;
1690
+ margin: 0;
1691
+ }
1692
+
1693
+ .stats-cards {
1694
+ display: flex;
1695
+ flex-wrap: wrap;
1696
+ gap: 1rem;
1697
+ padding: 1rem;
1698
+ }
1699
+
1700
+ .stat-card {
1701
+ flex: 1;
1702
+ min-width: 150px;
1703
+ padding: 1rem;
1704
+ text-align: center;
1705
+ border-radius: 4px;
1706
+ color: white;
1707
+ }
1708
+
1709
+ .stat-card.total { background: #3498db; }
1710
+ .stat-card.passed { background: #27ae60; }
1711
+ .stat-card.failed { background: #e74c3c; }
1712
+ .stat-card.pending { background: #f39c12; }
1713
+
1714
+ .stat-card h3 {
1715
+ font-size: 0.9rem;
1716
+ margin-bottom: 0.5rem;
1717
+ }
1718
+
1719
+ .stat-number {
1720
+ font-size: 2rem;
1721
+ font-weight: bold;
1722
+ }
1723
+
1724
+ .pie-chart-container {
1725
+ display: flex;
1726
+ justify-content: center;
1727
+ align-items: center;
1728
+ padding: 2rem 1rem;
1729
+ background: white;
1730
+ margin: 1rem 0;
1731
+ border-radius: 8px;
1732
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1733
+ }
1734
+
1735
+ #statsChart {
1736
+ max-width: 100%;
1737
+ height: auto;
1738
+ }
1739
+
1740
+ .test-item {
1741
+ border-bottom: 1px solid #eee;
1742
+ margin: 0;
1743
+ }
1744
+
1745
+ .test-item:last-child {
1746
+ border-bottom: none;
1747
+ }
1748
+
1749
+ .test-header {
1750
+ display: flex;
1751
+ align-items: center;
1752
+ padding: 1rem;
1753
+ cursor: pointer;
1754
+ transition: background-color 0.2s;
1755
+ }
1756
+
1757
+ .test-header:hover {
1758
+ background-color: #f8f9fa;
1759
+ }
1760
+
1761
+ .test-info {
1762
+ flex: 1;
1763
+ display: flex;
1764
+ flex-direction: column;
1765
+ gap: 0.25rem;
1766
+ }
1767
+
1768
+ .test-meta-line {
1769
+ display: flex;
1770
+ align-items: center;
1771
+ gap: 0.5rem;
1772
+ font-size: 0.9rem;
1773
+ }
1774
+
1775
+ .test-status {
1776
+ font-size: 1.2rem;
1777
+ margin-right: 0.5rem;
1778
+ }
1779
+
1780
+ .test-status.passed { color: #27ae60; }
1781
+ .test-status.failed { color: #e74c3c; }
1782
+ .test-status.pending { color: #f39c12; }
1783
+ .test-status.skipped { color: #95a5a6; }
1784
+
1785
+ .test-title {
1786
+ font-size: 1.1rem;
1787
+ font-weight: 500;
1788
+ margin: 0;
1789
+ }
1790
+
1791
+ .test-feature {
1792
+ background: #ecf0f1;
1793
+ padding: 0.25rem 0.5rem;
1794
+ border-radius: 4px;
1795
+ font-size: 0.8rem;
1796
+ color: #34495e;
1797
+ }
1798
+
1799
+ .test-uid {
1800
+ background: #e8f4fd;
1801
+ padding: 0.25rem 0.5rem;
1802
+ border-radius: 4px;
1803
+ font-size: 0.7rem;
1804
+ color: #2980b9;
1805
+ font-family: monospace;
1806
+ }
1807
+
1808
+ .retry-badge {
1809
+ background: #f39c12;
1810
+ color: white;
1811
+ padding: 0.25rem 0.5rem;
1812
+ border-radius: 4px;
1813
+ font-size: 0.7rem;
1814
+ font-weight: bold;
1815
+ }
1816
+
1817
+ .test-duration {
1818
+ font-size: 0.8rem;
1819
+ color: #7f8c8d;
1820
+ }
1821
+
1822
+ .test-details {
1823
+ display: none;
1824
+ padding: 1rem;
1825
+ background: #f8f9fa;
1826
+ border-top: 1px solid #e9ecef;
1827
+ }
1828
+
1829
+ .error-message {
1830
+ background: #fee;
1831
+ border: 1px solid #fcc;
1832
+ border-radius: 4px;
1833
+ padding: 1rem;
1834
+ margin-bottom: 1rem;
1835
+ }
1836
+
1837
+ .error-message pre {
1838
+ color: #c0392b;
1839
+ font-family: 'Courier New', monospace;
1840
+ font-size: 0.9rem;
1841
+ white-space: pre-wrap;
1842
+ word-wrap: break-word;
1843
+ }
1844
+
1845
+ .steps-section, .artifacts-section, .hooks-section {
1846
+ margin-top: 1rem;
1847
+ }
1848
+
1849
+ .steps-section h4, .artifacts-section h4, .hooks-section h4 {
1850
+ color: #34495e;
1851
+ margin-bottom: 0.5rem;
1852
+ font-size: 1rem;
1853
+ }
1854
+
1855
+ .hook-item {
1856
+ display: flex;
1857
+ align-items: center;
1858
+ padding: 0.5rem 0;
1859
+ border-bottom: 1px solid #ecf0f1;
1860
+ }
1861
+
1862
+ .hook-item:last-child {
1863
+ border-bottom: none;
1864
+ }
1865
+
1866
+ .hook-status {
1867
+ margin-right: 0.5rem;
1868
+ }
1869
+
1870
+ .hook-status.passed { color: #27ae60; }
1871
+ .hook-status.failed { color: #e74c3c; }
1872
+
1873
+ .hook-title {
1874
+ flex: 1;
1875
+ font-family: 'Courier New', monospace;
1876
+ font-size: 0.9rem;
1877
+ font-weight: bold;
1878
+ }
1879
+
1880
+ .hook-duration {
1881
+ font-size: 0.8rem;
1882
+ color: #7f8c8d;
1883
+ }
1884
+
1885
+ .hook-error {
1886
+ width: 100%;
1887
+ margin-top: 0.5rem;
1888
+ padding: 0.5rem;
1889
+ background: #fee;
1890
+ border: 1px solid #fcc;
1891
+ border-radius: 4px;
1892
+ color: #c0392b;
1893
+ font-size: 0.8rem;
1894
+ }
1895
+
1896
+ .step-item {
1897
+ display: flex;
1898
+ align-items: flex-start;
1899
+ padding: 0.5rem 0;
1900
+ border-bottom: 1px solid #ecf0f1;
1901
+ word-wrap: break-word;
1902
+ overflow-wrap: break-word;
1903
+ min-height: 2rem;
1904
+ }
1905
+
1906
+ .step-item:last-child {
1907
+ border-bottom: none;
1908
+ }
1909
+
1910
+ .step-status {
1911
+ margin-right: 0.5rem;
1912
+ flex-shrink: 0;
1913
+ margin-top: 0.2rem;
1914
+ }
1915
+
1916
+ .step-status.success { color: #27ae60; }
1917
+ .step-status.failed { color: #e74c3c; }
1918
+
1919
+ .step-title {
1920
+ flex: 1;
1921
+ font-family: 'Courier New', monospace;
1922
+ font-size: 0.9rem;
1923
+ word-wrap: break-word;
1924
+ overflow-wrap: break-word;
1925
+ line-height: 1.4;
1926
+ margin-right: 0.5rem;
1927
+ min-width: 0;
1928
+ }
1929
+
1930
+ .step-duration {
1931
+ font-size: 0.8rem;
1932
+ color: #7f8c8d;
1933
+ flex-shrink: 0;
1934
+ margin-top: 0.2rem;
1935
+ }
1936
+
1937
+ .artifacts-list {
1938
+ display: flex;
1939
+ flex-wrap: wrap;
1940
+ gap: 0.5rem;
1941
+ }
1942
+
1943
+ .artifact-image {
1944
+ max-width: 200px;
1945
+ max-height: 150px;
1946
+ border: 1px solid #ddd;
1947
+ border-radius: 4px;
1948
+ cursor: pointer;
1949
+ transition: transform 0.2s;
1950
+ }
1951
+
1952
+ .artifact-image:hover {
1953
+ transform: scale(1.05);
1954
+ }
1955
+
1956
+ .artifact-item {
1957
+ background: #ecf0f1;
1958
+ padding: 0.5rem;
1959
+ border-radius: 4px;
1960
+ font-size: 0.9rem;
1961
+ }
1962
+
1963
+ .modal {
1964
+ display: none;
1965
+ position: fixed;
1966
+ z-index: 1000;
1967
+ left: 0;
1968
+ top: 0;
1969
+ width: 100%;
1970
+ height: 100%;
1971
+ background-color: rgba(0,0,0,0.8);
1972
+ cursor: pointer;
1973
+ }
1974
+
1975
+ .modal img {
1976
+ position: absolute;
1977
+ top: 50%;
1978
+ left: 50%;
1979
+ transform: translate(-50%, -50%);
1980
+ max-width: 90%;
1981
+ max-height: 90%;
1982
+ border-radius: 4px;
1983
+ }
1984
+
1985
+ /* Enhanced screenshot styles for failed tests */
1986
+ .screenshots-section {
1987
+ margin-top: 1rem;
1988
+ }
1989
+
1990
+ .screenshots-section h4 {
1991
+ color: #e74c3c;
1992
+ margin-bottom: 0.75rem;
1993
+ font-size: 1rem;
1994
+ font-weight: 600;
1995
+ }
1996
+
1997
+ .screenshots-list {
1998
+ display: flex;
1999
+ flex-direction: column;
2000
+ gap: 1rem;
2001
+ }
2002
+
2003
+ .screenshot-container {
2004
+ border: 2px solid #e74c3c;
2005
+ border-radius: 8px;
2006
+ overflow: hidden;
2007
+ background: white;
2008
+ box-shadow: 0 4px 8px rgba(231, 76, 60, 0.1);
2009
+ }
2010
+
2011
+ .screenshot-header {
2012
+ background: #e74c3c;
2013
+ color: white;
2014
+ padding: 0.5rem 1rem;
2015
+ font-size: 0.9rem;
2016
+ font-weight: 500;
2017
+ }
2018
+
2019
+ .screenshot-label {
2020
+ display: flex;
2021
+ align-items: center;
2022
+ gap: 0.5rem;
2023
+ }
2024
+
2025
+ .failure-screenshot {
2026
+ width: 100%;
2027
+ max-width: 100%;
2028
+ height: auto;
2029
+ display: block;
2030
+ cursor: pointer;
2031
+ transition: opacity 0.2s;
2032
+ }
2033
+
2034
+ .failure-screenshot:hover {
2035
+ opacity: 0.9;
2036
+ }
2037
+
2038
+ .other-artifacts-section {
2039
+ margin-top: 1rem;
2040
+ }
2041
+
2042
+ /* Filter Controls */
2043
+ .filter-controls {
2044
+ display: flex;
2045
+ flex-wrap: wrap;
2046
+ gap: 1rem;
2047
+ padding: 1rem;
2048
+ background: #f8f9fa;
2049
+ }
2050
+
2051
+ .filter-group {
2052
+ display: flex;
2053
+ flex-direction: column;
2054
+ gap: 0.25rem;
2055
+ }
2056
+
2057
+ .filter-group label {
2058
+ font-size: 0.9rem;
2059
+ font-weight: 500;
2060
+ color: #34495e;
2061
+ }
2062
+
2063
+ .filter-group input,
2064
+ .filter-group select {
2065
+ padding: 0.5rem;
2066
+ border: 1px solid #ddd;
2067
+ border-radius: 4px;
2068
+ font-size: 0.9rem;
2069
+ min-width: 150px;
2070
+ }
2071
+
2072
+ .filter-group select[multiple] {
2073
+ height: auto;
2074
+ min-height: 80px;
2075
+ }
2076
+
2077
+ .filter-controls button {
2078
+ padding: 0.5rem 1rem;
2079
+ background: #3498db;
2080
+ color: white;
2081
+ border: none;
2082
+ border-radius: 4px;
2083
+ cursor: pointer;
2084
+ font-size: 0.9rem;
2085
+ align-self: flex-end;
2086
+ }
2087
+
2088
+ .filter-controls button:hover {
2089
+ background: #2980b9;
2090
+ }
2091
+
2092
+ /* Test Tags */
2093
+ .tags-section, .metadata-section, .notes-section, .retry-section {
2094
+ margin-top: 1rem;
2095
+ }
2096
+
2097
+ .tags-list {
2098
+ display: flex;
2099
+ flex-wrap: wrap;
2100
+ gap: 0.5rem;
2101
+ }
2102
+
2103
+ .test-tag {
2104
+ background: #3498db;
2105
+ color: white;
2106
+ padding: 0.25rem 0.5rem;
2107
+ border-radius: 12px;
2108
+ font-size: 0.8rem;
2109
+ }
2110
+
2111
+ /* Metadata */
2112
+ .metadata-list {
2113
+ display: flex;
2114
+ flex-direction: column;
2115
+ gap: 0.5rem;
2116
+ }
2117
+
2118
+ .meta-item {
2119
+ padding: 0.5rem;
2120
+ background: #f8f9fa;
2121
+ border-radius: 4px;
2122
+ border-left: 3px solid #3498db;
2123
+ }
2124
+
2125
+ .meta-key {
2126
+ font-weight: bold;
2127
+ color: #2c3e50;
2128
+ }
2129
+
2130
+ .meta-value {
2131
+ color: #34495e;
2132
+ font-family: monospace;
2133
+ }
2134
+
2135
+ /* Notes */
2136
+ .notes-list {
2137
+ display: flex;
2138
+ flex-direction: column;
2139
+ gap: 0.5rem;
2140
+ }
2141
+
2142
+ .note-item {
2143
+ padding: 0.5rem;
2144
+ border-radius: 4px;
2145
+ border-left: 3px solid #95a5a6;
2146
+ }
2147
+
2148
+ .note-item.note-info {
2149
+ background: #e8f4fd;
2150
+ border-left-color: #3498db;
2151
+ }
2152
+
2153
+ .note-item.note-warning {
2154
+ background: #fef9e7;
2155
+ border-left-color: #f39c12;
2156
+ }
2157
+
2158
+ .note-item.note-error {
2159
+ background: #fee;
2160
+ border-left-color: #e74c3c;
2161
+ }
2162
+
2163
+ .note-item.note-retry {
2164
+ background: #f0f8e8;
2165
+ border-left-color: #27ae60;
2166
+ }
2167
+
2168
+ .note-type {
2169
+ font-weight: bold;
2170
+ text-transform: uppercase;
2171
+ font-size: 0.8rem;
2172
+ }
2173
+
2174
+ /* Retry Information */
2175
+ .retry-info {
2176
+ padding: 0.5rem;
2177
+ background: #fef9e7;
2178
+ border-radius: 4px;
2179
+ border-left: 3px solid #f39c12;
2180
+ }
2181
+
2182
+ .retry-count {
2183
+ color: #d68910;
2184
+ font-weight: 500;
2185
+ }
2186
+
2187
+ /* Retries Section */
2188
+ .retry-item {
2189
+ padding: 1rem;
2190
+ margin-bottom: 1rem;
2191
+ border: 1px solid #f39c12;
2192
+ border-radius: 4px;
2193
+ background: #fef9e7;
2194
+ }
2195
+
2196
+ .retry-item h4 {
2197
+ color: #d68910;
2198
+ margin-bottom: 0.5rem;
2199
+ }
2200
+
2201
+ .retry-details {
2202
+ display: flex;
2203
+ gap: 1rem;
2204
+ align-items: center;
2205
+ font-size: 0.9rem;
2206
+ }
2207
+
2208
+ .status-badge {
2209
+ padding: 0.25rem 0.5rem;
2210
+ border-radius: 4px;
2211
+ font-size: 0.8rem;
2212
+ font-weight: bold;
2213
+ text-transform: uppercase;
2214
+ }
2215
+
2216
+ .status-badge.passed {
2217
+ background: #27ae60;
2218
+ color: white;
2219
+ }
2220
+
2221
+ .status-badge.failed {
2222
+ background: #e74c3c;
2223
+ color: white;
2224
+ }
2225
+
2226
+ .status-badge.pending {
2227
+ background: #f39c12;
2228
+ color: white;
2229
+ }
2230
+
2231
+ /* History Chart */
2232
+ .history-chart-container {
2233
+ padding: 2rem 1rem;
2234
+ display: flex;
2235
+ justify-content: center;
2236
+ }
2237
+
2238
+ #historyChart {
2239
+ max-width: 100%;
2240
+ height: auto;
2241
+ }
2242
+
2243
+ /* Hidden items for filtering */
2244
+ .test-item.filtered-out {
2245
+ display: none !important;
2246
+ }
2247
+
2248
+ /* System Info Section */
2249
+ .system-info-section {
2250
+ background: white;
2251
+ margin-bottom: 2rem;
2252
+ border-radius: 8px;
2253
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
2254
+ overflow: hidden;
2255
+ }
2256
+
2257
+ .system-info-header {
2258
+ background: #2c3e50;
2259
+ color: white;
2260
+ padding: 1rem;
2261
+ cursor: pointer;
2262
+ display: flex;
2263
+ justify-content: space-between;
2264
+ align-items: center;
2265
+ transition: background-color 0.2s;
2266
+ }
2267
+
2268
+ .system-info-header:hover {
2269
+ background: #34495e;
2270
+ }
2271
+
2272
+ .system-info-header h3 {
2273
+ margin: 0;
2274
+ font-size: 1.2rem;
2275
+ }
2276
+
2277
+ .toggle-icon {
2278
+ font-size: 1rem;
2279
+ transition: transform 0.3s ease;
2280
+ }
2281
+
2282
+ .toggle-icon.rotated {
2283
+ transform: rotate(-180deg);
2284
+ }
2285
+
2286
+ .system-info-content {
2287
+ display: none;
2288
+ padding: 1.5rem;
2289
+ background: #f8f9fa;
2290
+ border-top: 1px solid #e9ecef;
2291
+ }
2292
+
2293
+ .system-info-content.visible {
2294
+ display: block;
2295
+ }
2296
+
2297
+ .system-info-grid {
2298
+ display: grid;
2299
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
2300
+ gap: 1rem;
2301
+ }
2302
+
2303
+ .info-item {
2304
+ padding: 0.75rem;
2305
+ background: white;
2306
+ border-radius: 6px;
2307
+ border-left: 4px solid #3498db;
2308
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
2309
+ }
2310
+
2311
+ .info-key {
2312
+ font-weight: bold;
2313
+ color: #2c3e50;
2314
+ display: inline-block;
2315
+ min-width: 100px;
2316
+ }
2317
+
2318
+ .info-value {
2319
+ color: #34495e;
2320
+ font-family: 'Courier New', monospace;
2321
+ font-size: 0.9rem;
2322
+ }
2323
+
2324
+ /* BDD/Gherkin specific styles */
2325
+ .bdd-test {
2326
+ border-left: 4px solid #8e44ad;
2327
+ }
2328
+
2329
+ .bdd-badge {
2330
+ background: #8e44ad;
2331
+ color: white;
2332
+ padding: 0.25rem 0.5rem;
2333
+ border-radius: 4px;
2334
+ font-size: 0.7rem;
2335
+ font-weight: bold;
2336
+ }
2337
+
2338
+ .bdd-feature-section {
2339
+ margin-top: 1rem;
2340
+ padding: 1rem;
2341
+ background: #f8f9fa;
2342
+ border-left: 4px solid #8e44ad;
2343
+ border-radius: 4px;
2344
+ }
2345
+
2346
+ .feature-name {
2347
+ font-weight: bold;
2348
+ font-size: 1.1rem;
2349
+ color: #8e44ad;
2350
+ margin-bottom: 0.5rem;
2351
+ }
2352
+
2353
+ .feature-description {
2354
+ color: #34495e;
2355
+ font-style: italic;
2356
+ margin: 0.5rem 0;
2357
+ padding: 0.5rem;
2358
+ background: white;
2359
+ border-radius: 4px;
2360
+ }
2361
+
2362
+ .feature-file {
2363
+ font-size: 0.8rem;
2364
+ color: #7f8c8d;
2365
+ margin-top: 0.5rem;
2366
+ }
2367
+
2368
+ .feature-tags {
2369
+ display: flex;
2370
+ flex-wrap: wrap;
2371
+ gap: 0.25rem;
2372
+ margin: 0.5rem 0;
2373
+ }
2374
+
2375
+ .feature-tag {
2376
+ background: #8e44ad;
2377
+ color: white;
2378
+ padding: 0.2rem 0.4rem;
2379
+ border-radius: 8px;
2380
+ font-size: 0.7rem;
2381
+ }
2382
+
2383
+ .bdd-steps-section {
2384
+ margin-top: 1rem;
2385
+ }
2386
+
2387
+ .bdd-steps-section h4 {
2388
+ color: #8e44ad;
2389
+ margin-bottom: 0.5rem;
2390
+ font-size: 1rem;
2391
+ }
2392
+
2393
+ .bdd-step-item {
2394
+ display: flex;
2395
+ align-items: flex-start;
2396
+ padding: 0.5rem 0;
2397
+ border-bottom: 1px solid #ecf0f1;
2398
+ font-family: 'Segoe UI', sans-serif;
2399
+ word-wrap: break-word;
2400
+ overflow-wrap: break-word;
2401
+ min-height: 2rem;
2402
+ }
2403
+
2404
+ .bdd-step-item:last-child {
2405
+ border-bottom: none;
2406
+ }
2407
+
2408
+ .bdd-keyword {
2409
+ font-weight: bold;
2410
+ color: #8e44ad;
2411
+ margin-right: 0.5rem;
2412
+ min-width: 60px;
2413
+ text-align: left;
2414
+ flex-shrink: 0;
2415
+ }
2416
+
2417
+ .bdd-step-text {
2418
+ flex: 1;
2419
+ color: #2c3e50;
2420
+ margin-right: 0.5rem;
2421
+ word-wrap: break-word;
2422
+ overflow-wrap: break-word;
2423
+ line-height: 1.4;
2424
+ min-width: 0;
2425
+ }
2426
+
2427
+ .step-comment {
2428
+ width: 100%;
2429
+ margin-top: 0.5rem;
2430
+ padding: 0.5rem;
2431
+ background: #f8f9fa;
2432
+ border-left: 3px solid #8e44ad;
2433
+ font-style: italic;
2434
+ color: #6c757d;
2435
+ word-wrap: break-word;
2436
+ overflow-wrap: break-word;
2437
+ line-height: 1.4;
2438
+ }
2439
+
2440
+ @media (max-width: 768px) {
2441
+ .stats-cards {
2442
+ flex-direction: column;
2443
+ }
2444
+
2445
+ .test-header {
2446
+ flex-direction: column;
2447
+ align-items: stretch;
2448
+ gap: 0.5rem;
2449
+ }
2450
+
2451
+ .test-feature, .test-duration {
2452
+ align-self: flex-start;
2453
+ }
2454
+ }
2455
+ `
2456
+ }
2457
+
2458
+ function getJsScripts() {
2459
+ return `
2460
+ function toggleTestDetails(testId) {
2461
+ const details = document.getElementById('details-' + testId);
2462
+ if (details.style.display === 'none' || details.style.display === '') {
2463
+ details.style.display = 'block';
2464
+ } else {
2465
+ details.style.display = 'none';
2466
+ }
2467
+ }
2468
+
2469
+ function openImageModal(src) {
2470
+ const modal = document.getElementById('imageModal');
2471
+ const modalImg = document.getElementById('modalImage');
2472
+ modalImg.src = src;
2473
+ modal.style.display = 'block';
2474
+ }
2475
+
2476
+ function closeImageModal() {
2477
+ const modal = document.getElementById('imageModal');
2478
+ modal.style.display = 'none';
2479
+ }
2480
+
2481
+ function toggleSystemInfo() {
2482
+ const content = document.getElementById('systemInfoContent');
2483
+ const icon = document.querySelector('.toggle-icon');
2484
+
2485
+ if (content.classList.contains('visible')) {
2486
+ content.classList.remove('visible');
2487
+ icon.classList.remove('rotated');
2488
+ } else {
2489
+ content.classList.add('visible');
2490
+ icon.classList.add('rotated');
2491
+ }
2492
+ }
2493
+
2494
+ // Filter functionality
2495
+ function applyFilters() {
2496
+ const statusFilter = Array.from(document.getElementById('statusFilter').selectedOptions).map(opt => opt.value);
2497
+ const featureFilter = document.getElementById('featureFilter').value.toLowerCase();
2498
+ const tagFilter = document.getElementById('tagFilter').value.toLowerCase();
2499
+ const retryFilter = document.getElementById('retryFilter').value;
2500
+ const typeFilter = document.getElementById('typeFilter').value;
2501
+
2502
+ const testItems = document.querySelectorAll('.test-item');
2503
+
2504
+ testItems.forEach(item => {
2505
+ let shouldShow = true;
2506
+
2507
+ // Status filter
2508
+ if (statusFilter.length > 0) {
2509
+ const testStatus = item.dataset.status;
2510
+ if (!statusFilter.includes(testStatus)) {
2511
+ shouldShow = false;
2512
+ }
2513
+ }
2514
+
2515
+ // Feature filter
2516
+ if (featureFilter && shouldShow) {
2517
+ const feature = (item.dataset.feature || '').toLowerCase();
2518
+ if (!feature.includes(featureFilter)) {
2519
+ shouldShow = false;
2520
+ }
2521
+ }
2522
+
2523
+ // Tag filter
2524
+ if (tagFilter && shouldShow) {
2525
+ const tags = (item.dataset.tags || '').toLowerCase();
2526
+ if (!tags.includes(tagFilter)) {
2527
+ shouldShow = false;
2528
+ }
2529
+ }
2530
+
2531
+ // Retry filter
2532
+ if (retryFilter !== 'all' && shouldShow) {
2533
+ const retries = parseInt(item.dataset.retries || '0');
2534
+ if (retryFilter === 'retried' && retries === 0) {
2535
+ shouldShow = false;
2536
+ } else if (retryFilter === 'no-retries' && retries > 0) {
2537
+ shouldShow = false;
2538
+ }
2539
+ }
2540
+
2541
+ // Test type filter (BDD/Gherkin vs Regular)
2542
+ if (typeFilter !== 'all' && shouldShow) {
2543
+ const testType = item.dataset.type || 'regular';
2544
+ if (typeFilter !== testType) {
2545
+ shouldShow = false;
2546
+ }
2547
+ }
2548
+
2549
+ if (shouldShow) {
2550
+ item.classList.remove('filtered-out');
2551
+ } else {
2552
+ item.classList.add('filtered-out');
2553
+ }
2554
+ });
2555
+
2556
+ updateFilteredStats();
2557
+ }
2558
+
2559
+ function resetFilters() {
2560
+ document.getElementById('statusFilter').selectedIndex = -1;
2561
+ document.getElementById('featureFilter').value = '';
2562
+ document.getElementById('tagFilter').value = '';
2563
+ document.getElementById('retryFilter').value = 'all';
2564
+ document.getElementById('typeFilter').value = 'all';
2565
+
2566
+ document.querySelectorAll('.test-item').forEach(item => {
2567
+ item.classList.remove('filtered-out');
2568
+ });
2569
+
2570
+ updateFilteredStats();
2571
+ }
2572
+
2573
+ function updateFilteredStats() {
2574
+ const visibleTests = document.querySelectorAll('.test-item:not(.filtered-out)');
2575
+ const totalVisible = visibleTests.length;
2576
+
2577
+ // Update the title to show filtered count
2578
+ const testsSection = document.querySelector('.tests-section h2');
2579
+ const totalTests = document.querySelectorAll('.test-item').length;
2580
+
2581
+ if (totalVisible !== totalTests) {
2582
+ testsSection.textContent = 'Test Results (' + totalVisible + ' of ' + totalTests + ' shown)';
2583
+ } else {
2584
+ testsSection.textContent = 'Test Results';
2585
+ }
2586
+ }
2587
+
2588
+ // Draw pie chart using canvas
2589
+ function drawPieChart() {
2590
+ const canvas = document.getElementById('statsChart');
2591
+ if (!canvas) return;
2592
+
2593
+ const ctx = canvas.getContext('2d');
2594
+ const data = window.chartData;
2595
+
2596
+ if (!data) return;
2597
+
2598
+ const centerX = canvas.width / 2;
2599
+ const centerY = canvas.height / 2;
2600
+ const radius = Math.min(centerX, centerY) - 20;
2601
+
2602
+ const total = data.passed + data.failed + data.pending;
2603
+ if (total === 0) {
2604
+ // Draw empty circle for no tests
2605
+ ctx.strokeStyle = '#ddd';
2606
+ ctx.lineWidth = 2;
2607
+ ctx.beginPath();
2608
+ ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
2609
+ ctx.stroke();
2610
+ ctx.fillStyle = '#888';
2611
+ ctx.font = '16px Arial';
2612
+ ctx.textAlign = 'center';
2613
+ ctx.fillText('No Tests', centerX, centerY);
2614
+ return;
2615
+ }
2616
+
2617
+ let currentAngle = -Math.PI / 2; // Start from top
2618
+
2619
+ // Calculate percentages
2620
+ const passedPercent = Math.round((data.passed / total) * 100);
2621
+ const failedPercent = Math.round((data.failed / total) * 100);
2622
+ const pendingPercent = Math.round((data.pending / total) * 100);
2623
+
2624
+ // Draw passed segment
2625
+ if (data.passed > 0) {
2626
+ const angle = (data.passed / total) * 2 * Math.PI;
2627
+ ctx.beginPath();
2628
+ ctx.moveTo(centerX, centerY);
2629
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
2630
+ ctx.closePath();
2631
+ ctx.fillStyle = '#27ae60';
2632
+ ctx.fill();
2633
+
2634
+ // Add percentage text on segment if significant enough
2635
+ if (passedPercent >= 10) {
2636
+ const textAngle = currentAngle + angle / 2;
2637
+ const textRadius = radius * 0.7;
2638
+ const textX = centerX + Math.cos(textAngle) * textRadius;
2639
+ const textY = centerY + Math.sin(textAngle) * textRadius;
2640
+
2641
+ ctx.fillStyle = '#fff';
2642
+ ctx.font = 'bold 14px Arial';
2643
+ ctx.textAlign = 'center';
2644
+ ctx.textBaseline = 'middle';
2645
+ ctx.fillText(passedPercent + '%', textX, textY);
2646
+ }
2647
+
2648
+ currentAngle += angle;
2649
+ }
2650
+
2651
+ // Draw failed segment
2652
+ if (data.failed > 0) {
2653
+ const angle = (data.failed / total) * 2 * Math.PI;
2654
+ ctx.beginPath();
2655
+ ctx.moveTo(centerX, centerY);
2656
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
2657
+ ctx.closePath();
2658
+ ctx.fillStyle = '#e74c3c';
2659
+ ctx.fill();
2660
+
2661
+ // Add percentage text on segment if significant enough
2662
+ if (failedPercent >= 10) {
2663
+ const textAngle = currentAngle + angle / 2;
2664
+ const textRadius = radius * 0.7;
2665
+ const textX = centerX + Math.cos(textAngle) * textRadius;
2666
+ const textY = centerY + Math.sin(textAngle) * textRadius;
2667
+
2668
+ ctx.fillStyle = '#fff';
2669
+ ctx.font = 'bold 14px Arial';
2670
+ ctx.textAlign = 'center';
2671
+ ctx.textBaseline = 'middle';
2672
+ ctx.fillText(failedPercent + '%', textX, textY);
2673
+ }
2674
+
2675
+ currentAngle += angle;
2676
+ }
2677
+
2678
+ // Draw pending segment
2679
+ if (data.pending > 0) {
2680
+ const angle = (data.pending / total) * 2 * Math.PI;
2681
+ ctx.beginPath();
2682
+ ctx.moveTo(centerX, centerY);
2683
+ ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + angle);
2684
+ ctx.closePath();
2685
+ ctx.fillStyle = '#f39c12';
2686
+ ctx.fill();
2687
+
2688
+ // Add percentage text on segment if significant enough
2689
+ if (pendingPercent >= 10) {
2690
+ const textAngle = currentAngle + angle / 2;
2691
+ const textRadius = radius * 0.7;
2692
+ const textX = centerX + Math.cos(textAngle) * textRadius;
2693
+ const textY = centerY + Math.sin(textAngle) * textRadius;
2694
+
2695
+ ctx.fillStyle = '#fff';
2696
+ ctx.font = 'bold 14px Arial';
2697
+ ctx.textAlign = 'center';
2698
+ ctx.textBaseline = 'middle';
2699
+ ctx.fillText(pendingPercent + '%', textX, textY);
2700
+ }
2701
+ }
2702
+
2703
+ // Add legend with percentages
2704
+ const legendY = centerY + radius + 40;
2705
+ ctx.font = '14px Arial';
2706
+ ctx.textAlign = 'left';
2707
+ ctx.textBaseline = 'alphabetic';
2708
+
2709
+ let legendX = centerX - 150;
2710
+
2711
+ // Passed legend
2712
+ ctx.fillStyle = '#27ae60';
2713
+ ctx.fillRect(legendX, legendY, 15, 15);
2714
+ ctx.fillStyle = '#333';
2715
+ ctx.fillText('Passed (' + data.passed + ' - ' + passedPercent + '%)', legendX + 20, legendY + 12);
2716
+
2717
+ // Failed legend
2718
+ legendX += 130;
2719
+ ctx.fillStyle = '#e74c3c';
2720
+ ctx.fillRect(legendX, legendY, 15, 15);
2721
+ ctx.fillStyle = '#333';
2722
+ ctx.fillText('Failed (' + data.failed + ' - ' + failedPercent + '%)', legendX + 20, legendY + 12);
2723
+
2724
+ // Pending legend
2725
+ if (data.pending > 0) {
2726
+ legendX += 120;
2727
+ ctx.fillStyle = '#f39c12';
2728
+ ctx.fillRect(legendX, legendY, 15, 15);
2729
+ ctx.fillStyle = '#333';
2730
+ ctx.fillText('Pending (' + data.pending + ' - ' + pendingPercent + '%)', legendX + 20, legendY + 12);
2731
+ }
2732
+ }
2733
+
2734
+ // Draw history chart
2735
+ function drawHistoryChart() {
2736
+ const canvas = document.getElementById('historyChart');
2737
+
2738
+ if (!canvas || !window.testData || !window.testData.history || window.testData.history.length === 0) {
2739
+ return;
2740
+ }
2741
+
2742
+ const ctx = canvas.getContext('2d');
2743
+ const history = window.testData.history.slice().reverse(); // Most recent last
2744
+ console.log('History chart - Total data points:', window.testData.history.length);
2745
+ console.log('History chart - Processing points:', history.length);
2746
+ console.log('History chart - Raw history data:', window.testData.history);
2747
+ console.log('History chart - Reversed history:', history);
2748
+
2749
+ const padding = 60;
2750
+ const bottomPadding = 80; // Extra space for timestamps
2751
+ const chartWidth = canvas.width - 2 * padding;
2752
+ const chartHeight = canvas.height - padding - bottomPadding;
2753
+
2754
+ // Clear canvas
2755
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2756
+
2757
+ // Calculate success rates and max values
2758
+ const dataPoints = history.map((run, index) => {
2759
+ const total = run.stats.tests || 0;
2760
+ const passed = run.stats.passes || 0;
2761
+ const failed = run.stats.failures || 0;
2762
+ const successRate = total > 0 ? (passed / total) * 100 : 0;
2763
+ const timestamp = new Date(run.timestamp);
2764
+
2765
+ return {
2766
+ index,
2767
+ timestamp,
2768
+ total,
2769
+ passed,
2770
+ failed,
2771
+ successRate,
2772
+ duration: run.duration || 0,
2773
+ retries: run.retries || 0
2774
+ };
2775
+ });
2776
+
2777
+ console.log('History chart - Data points created:', dataPoints.length);
2778
+ console.log('History chart - Data points:', dataPoints);
2779
+
2780
+ const maxTests = Math.max(...dataPoints.map(d => d.total));
2781
+ const maxSuccessRate = 100;
2782
+
2783
+ if (maxTests === 0) return;
2784
+
2785
+ // Draw background
2786
+ ctx.fillStyle = '#fafafa';
2787
+ ctx.fillRect(padding, padding, chartWidth, chartHeight);
2788
+
2789
+ // Draw axes
2790
+ ctx.strokeStyle = '#333';
2791
+ ctx.lineWidth = 2;
2792
+ ctx.beginPath();
2793
+ ctx.moveTo(padding, padding);
2794
+ ctx.lineTo(padding, padding + chartHeight);
2795
+ ctx.lineTo(padding + chartWidth, padding + chartHeight);
2796
+ ctx.stroke();
2797
+
2798
+ // Draw grid lines
2799
+ ctx.strokeStyle = '#e0e0e0';
2800
+ ctx.lineWidth = 1;
2801
+ for (let i = 1; i <= 4; i++) {
2802
+ const y = padding + (chartHeight * i / 4);
2803
+ ctx.beginPath();
2804
+ ctx.moveTo(padding, y);
2805
+ ctx.lineTo(padding + chartWidth, y);
2806
+ ctx.stroke();
2807
+ }
2808
+
2809
+ // Calculate positions
2810
+ const stepX = dataPoints.length > 1 ? chartWidth / (dataPoints.length - 1) : chartWidth / 2;
2811
+
2812
+ // Draw success rate area chart
2813
+ ctx.fillStyle = 'rgba(39, 174, 96, 0.1)';
2814
+ ctx.strokeStyle = '#27ae60';
2815
+ ctx.lineWidth = 3;
2816
+ ctx.beginPath();
2817
+
2818
+ dataPoints.forEach((point, index) => {
2819
+ const x = dataPoints.length === 1 ? padding + chartWidth / 2 : padding + (index * stepX);
2820
+ const y = padding + chartHeight - (point.successRate / maxSuccessRate) * chartHeight;
2821
+
2822
+ if (index === 0) {
2823
+ ctx.moveTo(x, padding + chartHeight);
2824
+ ctx.lineTo(x, y);
2825
+ } else {
2826
+ ctx.lineTo(x, y);
2827
+ }
2828
+
2829
+ point.x = x;
2830
+ point.y = y;
2831
+ });
2832
+
2833
+ // Close the area
2834
+ if (dataPoints.length > 0) {
2835
+ const lastPoint = dataPoints[dataPoints.length - 1];
2836
+ ctx.lineTo(lastPoint.x, padding + chartHeight);
2837
+ ctx.closePath();
2838
+ ctx.fill();
2839
+ }
2840
+
2841
+ // Draw success rate line
2842
+ ctx.strokeStyle = '#27ae60';
2843
+ ctx.lineWidth = 3;
2844
+ ctx.beginPath();
2845
+ dataPoints.forEach((point, index) => {
2846
+ if (index === 0) {
2847
+ ctx.moveTo(point.x, point.y);
2848
+ } else {
2849
+ ctx.lineTo(point.x, point.y);
2850
+ }
2851
+ });
2852
+ ctx.stroke();
2853
+
2854
+ // Draw data points with enhanced styling
2855
+ dataPoints.forEach(point => {
2856
+ // Outer ring based on status
2857
+ const ringColor = point.failed > 0 ? '#e74c3c' : '#27ae60';
2858
+ ctx.strokeStyle = ringColor;
2859
+ ctx.lineWidth = 3;
2860
+ ctx.beginPath();
2861
+ ctx.arc(point.x, point.y, 8, 0, 2 * Math.PI);
2862
+ ctx.stroke();
2863
+
2864
+ // Inner circle
2865
+ ctx.fillStyle = point.failed > 0 ? '#e74c3c' : '#27ae60';
2866
+ ctx.beginPath();
2867
+ ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI);
2868
+ ctx.fill();
2869
+
2870
+ // White center dot
2871
+ ctx.fillStyle = '#fff';
2872
+ ctx.beginPath();
2873
+ ctx.arc(point.x, point.y, 2, 0, 2 * Math.PI);
2874
+ ctx.fill();
2875
+ });
2876
+
2877
+ // Y-axis labels (Success Rate %)
2878
+ ctx.fillStyle = '#666';
2879
+ ctx.font = '11px Arial';
2880
+ ctx.textAlign = 'right';
2881
+ for (let i = 0; i <= 4; i++) {
2882
+ const value = Math.round((maxSuccessRate * i) / 4);
2883
+ const y = padding + chartHeight - (chartHeight * i / 4);
2884
+ ctx.fillText(value + '%', padding - 10, y + 4);
2885
+ }
2886
+
2887
+ // X-axis labels (Timestamps)
2888
+ ctx.textAlign = 'center';
2889
+ ctx.font = '10px Arial';
2890
+ dataPoints.forEach((point, index) => {
2891
+ const timeStr = point.timestamp.toLocaleTimeString('en-US', {
2892
+ hour: '2-digit',
2893
+ minute: '2-digit',
2894
+ hour12: false
2895
+ });
2896
+ const dateStr = point.timestamp.toLocaleDateString('en-US', {
2897
+ month: 'short',
2898
+ day: 'numeric'
2899
+ });
2900
+
2901
+ console.log('Drawing label ' + index + ': ' + timeStr + ' at x=' + point.x);
2902
+ ctx.fillText(timeStr, point.x, padding + chartHeight + 15);
2903
+ ctx.fillText(dateStr, point.x, padding + chartHeight + 30);
2904
+ });
2905
+
2906
+ // Enhanced legend with statistics
2907
+ const legendY = 25;
2908
+ ctx.font = '12px Arial';
2909
+ ctx.textAlign = 'left';
2910
+
2911
+ // Success rate legend
2912
+ ctx.fillStyle = '#27ae60';
2913
+ ctx.fillRect(padding + 20, legendY, 15, 15);
2914
+ ctx.fillStyle = '#333';
2915
+ ctx.fillText('Success Rate', padding + 40, legendY + 12);
2916
+
2917
+ // Current stats
2918
+ if (dataPoints.length > 0) {
2919
+ const latest = dataPoints[dataPoints.length - 1];
2920
+ const trend = dataPoints.length > 1 ?
2921
+ (latest.successRate - dataPoints[dataPoints.length - 2].successRate) : 0;
2922
+ const trendIcon = trend > 0 ? '↗' : trend < 0 ? '↘' : '→';
2923
+ const trendColor = trend > 0 ? '#27ae60' : trend < 0 ? '#e74c3c' : '#666';
2924
+
2925
+ ctx.fillStyle = '#666';
2926
+ ctx.fillText('Latest: ' + latest.successRate.toFixed(1) + '%', padding + 150, legendY + 12);
2927
+
2928
+ ctx.fillStyle = trendColor;
2929
+ ctx.fillText(trendIcon + ' ' + Math.abs(trend).toFixed(1) + '%', padding + 240, legendY + 12);
2930
+ }
2931
+
2932
+ // Chart title
2933
+ ctx.fillStyle = '#333';
2934
+ ctx.font = 'bold 14px Arial';
2935
+ ctx.textAlign = 'center';
2936
+ ctx.fillText('Test Success Rate History', canvas.width / 2, 20);
2937
+ }
2938
+
2939
+ // Initialize charts and filters
2940
+ document.addEventListener('DOMContentLoaded', function() {
2941
+
2942
+ // Draw charts
2943
+ drawPieChart();
2944
+ drawHistoryChart();
2945
+
2946
+ // Set up filter event listeners
2947
+ document.getElementById('statusFilter').addEventListener('change', applyFilters);
2948
+ document.getElementById('featureFilter').addEventListener('input', applyFilters);
2949
+ document.getElementById('tagFilter').addEventListener('input', applyFilters);
2950
+ document.getElementById('retryFilter').addEventListener('change', applyFilters);
2951
+ document.getElementById('typeFilter').addEventListener('change', applyFilters);
2952
+ });
2953
+ `
2954
+ }
2955
+ }