@arghajit/playwright-pulse-report 0.1.0

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,1539 @@
1
+ #!/usr/bin/env node
2
+ // Using Node.js syntax compatible with `.mjs`
3
+ import * as fs from "fs/promises";
4
+ import path from "path";
5
+ import * as d3 from "d3";
6
+ import { JSDOM } from "jsdom";
7
+ // Use dynamic import for chalk as it's ESM only
8
+ let chalk;
9
+ try {
10
+ chalk = (await import("chalk")).default;
11
+ } catch (e) {
12
+ console.warn("Chalk could not be imported. Using plain console logs.");
13
+ chalk = {
14
+ green: (text) => text,
15
+ red: (text) => text,
16
+ yellow: (text) => text,
17
+ blue: (text) => text,
18
+ bold: (text) => text,
19
+ gray: (text) => text,
20
+ };
21
+ }
22
+
23
+ // Default configuration
24
+ const DEFAULT_OUTPUT_DIR = "pulse-report-output";
25
+ const DEFAULT_JSON_FILE = "playwright-pulse-report.json";
26
+ const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
27
+
28
+ // Helper functions
29
+ function sanitizeHTML(str) {
30
+ if (str === null || str === undefined) return "";
31
+ return String(str)
32
+ .replace(/&/g, "&")
33
+ .replace(/</g, "&lt;")
34
+ .replace(/>/g, "&gt;")
35
+ .replace(/"/g, "&quot;")
36
+ .replace(/'/g, "&#039;");
37
+ }
38
+
39
+ function formatDuration(ms) {
40
+ if (ms === undefined || ms === null || ms < 0) return "0.0s";
41
+ return (ms / 1000).toFixed(1) + "s";
42
+ }
43
+
44
+ function formatDate(dateStrOrDate) {
45
+ if (!dateStrOrDate) return "N/A";
46
+ try {
47
+ const date = new Date(dateStrOrDate);
48
+ if (isNaN(date.getTime())) return "Invalid Date";
49
+ return date.toLocaleString();
50
+ } catch (e) {
51
+ return "Invalid Date";
52
+ }
53
+ }
54
+
55
+ function getStatusClass(status) {
56
+ switch (status) {
57
+ case "passed":
58
+ return "status-passed";
59
+ case "failed":
60
+ return "status-failed";
61
+ case "skipped":
62
+ return "status-skipped";
63
+ default:
64
+ return "";
65
+ }
66
+ }
67
+
68
+ function getStatusIcon(status) {
69
+ switch (status) {
70
+ case "passed":
71
+ return "✅";
72
+ case "failed":
73
+ return "❌";
74
+ case "skipped":
75
+ return "⏭️";
76
+ default:
77
+ return "❓";
78
+ }
79
+ }
80
+
81
+ function generatePieChartD3(data, width = 300, height = 300) {
82
+ const { document } = new JSDOM().window;
83
+ const body = d3.select(document.body);
84
+
85
+ // Calculate passed percentage
86
+ const total = data.reduce((sum, d) => sum + d.value, 0);
87
+ const passedPercentage =
88
+ total > 0
89
+ ? Math.round(
90
+ ((data.find((d) => d.label === "Passed")?.value || 0) / total) * 100
91
+ )
92
+ : 0;
93
+
94
+ // Chart dimensions
95
+ const radius = Math.min(width, height) / 2 - 50; // Reduced radius for legend space
96
+ const legendRectSize = 15;
97
+ const legendSpacing = 8;
98
+
99
+ // Pie generator
100
+ const pie = d3
101
+ .pie()
102
+ .value((d) => d.value)
103
+ .sort(null);
104
+ const arc = d3.arc().innerRadius(0).outerRadius(radius);
105
+
106
+ // Colors
107
+ const color = d3
108
+ .scaleOrdinal()
109
+ .domain(data.map((d) => d.label))
110
+ .range(["#4CAF50", "#F44336", "#FFC107"]);
111
+
112
+ // Create SVG with more width for legend
113
+ const svg = body
114
+ .append("svg")
115
+ .attr("width", width + 100) // Extra width for legend
116
+ .attr("height", height)
117
+ .append("g")
118
+ .attr("transform", `translate(${width / 2},${height / 2})`);
119
+
120
+ // Tooltip setup
121
+ const tooltip = body
122
+ .append("div")
123
+ .style("opacity", 0)
124
+ .style("position", "absolute")
125
+ .style("background", "white")
126
+ .style("padding", "5px 10px")
127
+ .style("border-radius", "4px")
128
+ .style("box-shadow", "0 2px 5px rgba(0,0,0,0.1)");
129
+
130
+ // Draw pie slices
131
+ const arcs = svg
132
+ .selectAll(".arc")
133
+ .data(pie(data))
134
+ .enter()
135
+ .append("g")
136
+ .attr("class", "arc");
137
+
138
+ arcs
139
+ .append("path")
140
+ .attr("d", arc)
141
+ .attr("fill", (d) => color(d.data.label))
142
+ .style("stroke", "#fff")
143
+ .style("stroke-width", 2)
144
+ .on("mouseover", function (event, d) {
145
+ tooltip.transition().style("opacity", 1);
146
+ tooltip
147
+ .html(
148
+ `${d.data.label}: ${d.data.value} (${Math.round(
149
+ (d.data.value / total) * 100
150
+ )}%)`
151
+ )
152
+ .style("left", event.pageX + 10 + "px")
153
+ .style("top", event.pageY - 28 + "px");
154
+ })
155
+ .on("mouseout", () => tooltip.transition().style("opacity", 0));
156
+
157
+ // Center percentage
158
+ svg
159
+ .append("text")
160
+ .attr("text-anchor", "middle")
161
+ .attr("dy", ".3em")
162
+ .style("font-size", "24px")
163
+ .style("font-weight", "bold")
164
+ .text(`${passedPercentage}%`);
165
+
166
+ // Legend - positioned to the right
167
+ const legend = svg
168
+ .selectAll(".legend")
169
+ .data(color.domain())
170
+ .enter()
171
+ .append("g")
172
+ .attr("class", "legend")
173
+ .attr(
174
+ "transform",
175
+ (d, i) =>
176
+ `translate(${radius + 20},${i * (legendRectSize + legendSpacing) - 40})`
177
+ ); // Moved right
178
+
179
+ legend
180
+ .append("rect")
181
+ .attr("width", legendRectSize)
182
+ .attr("height", legendRectSize)
183
+ .style("fill", color)
184
+ .style("stroke", color);
185
+
186
+ legend
187
+ .append("text")
188
+ .attr("x", legendRectSize + 5)
189
+ .attr("y", legendRectSize - 2)
190
+ .text((d) => d)
191
+ .style("font-size", "12px")
192
+ .style("text-anchor", "start");
193
+
194
+ return `
195
+ <div class="pie-chart-container">
196
+ <h3>Test Distribution Chart</h3>
197
+ ${body.html()}
198
+ <style>
199
+ .pie-chart-container {
200
+ display: flex;
201
+ justify-content: center;
202
+ margin: 20px 0;
203
+ }
204
+ .pie-chart-container svg {
205
+ display: block;
206
+ margin: 0 auto;
207
+ }
208
+ .pie-chart-container h3 {
209
+ text-align: center;
210
+ margin: 0 0 10px;
211
+ font-size: 16px;
212
+ color: var(--text-color);
213
+ }
214
+ </style>
215
+ </div>
216
+ `;
217
+ }
218
+
219
+ // Process the JSON data to extract suites information
220
+ function getSuitesData(results) {
221
+ const suitesMap = new Map();
222
+
223
+ results.forEach((test) => {
224
+ const browser = test.name.split(" > ")[1]; // Extract browser (chromium/firefox/webkit)
225
+ const suiteName = test.suiteName;
226
+ const key = `${suiteName}|${browser}`;
227
+
228
+ if (!suitesMap.has(key)) {
229
+ suitesMap.set(key, {
230
+ id: test.id,
231
+ name: `${suiteName} (${browser})`,
232
+ status: test.status,
233
+ count: 0,
234
+ });
235
+ }
236
+ suitesMap.get(key).count++;
237
+ });
238
+
239
+ return Array.from(suitesMap.values());
240
+ }
241
+
242
+ // Generate suites widget (updated for your data)
243
+ function generateSuitesWidget(suitesData) {
244
+ return `
245
+ <div class="suites-widget">
246
+ <div class="suites-header">
247
+ <h2>Test Suites</h2>
248
+ <div class="summary-badge">
249
+ ${suitesData.length} suites • ${suitesData.reduce(
250
+ (sum, suite) => sum + suite.count,
251
+ 0
252
+ )} tests
253
+ </div>
254
+ </div>
255
+
256
+ <div class="suites-grid">
257
+ ${suitesData
258
+ .map(
259
+ (suite) => `
260
+ <div class="suite-card ${suite.status}">
261
+ <div class="suite-meta">
262
+ <span class="browser-tag">${suite.name
263
+ .split("(")
264
+ .pop()
265
+ .replace(")", "")}</span>
266
+ <span class="test-count">${suite.count} test${
267
+ suite.count !== 1 ? "s" : ""
268
+ }</span>
269
+ </div>
270
+ <span class="browser-name">${suite.name
271
+ .split(" (")[1]
272
+ .replace(")", "")}</span>
273
+ </div>
274
+ `
275
+ )
276
+ .join("")}
277
+ </div>
278
+
279
+ <style>
280
+ .suites-widget {
281
+ background: linear-gradient(to bottom right, #f0f4ff, #ffffff);
282
+ border-radius: 16px;
283
+ padding: 10px;
284
+ box-shadow: 0 4px 20px rgba(0,0,0,0.05);
285
+ font-family: 'Segoe UI', Roboto, sans-serif;
286
+ height: 100%;
287
+ }
288
+ span.browser-name {
289
+ background-color: #265685;
290
+ font-size: 0.875rem;
291
+ color: #fff;
292
+ padding: 3px;
293
+ border-radius: 4px;
294
+ }
295
+
296
+ .suites-header {
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 16px;
300
+ margin-bottom: 24px;
301
+ }
302
+
303
+ .suites-header h2 {
304
+ font-size: 20px;
305
+ font-weight: 600;
306
+ margin: 0;
307
+ color: #1a202c;
308
+ }
309
+
310
+ .summary-badge {
311
+ background: #f8fafc;
312
+ color: #64748b;
313
+ padding: 4px 12px;
314
+ border-radius: 12px;
315
+ font-size: 14px;
316
+ }
317
+
318
+ .suites-grid {
319
+ display: grid;
320
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
321
+ gap: 16px;
322
+ }
323
+
324
+ .suite-card {
325
+ background: #e6e6e6;
326
+ border-radius: 12px;
327
+ padding: 18px;
328
+ border: 1px solid #f1f5f9;
329
+ transition: all 0.2s ease;
330
+ }
331
+
332
+ .suite-card:hover {
333
+ transform: translateY(-2px);
334
+ box-shadow: 0 6px 12px rgba(0,0,0,0.08);
335
+ border-color: #e2e8f0;
336
+ }
337
+
338
+ .suite-meta {
339
+ display: flex;
340
+ justify-content: space-between;
341
+ align-items: center;
342
+ margin-bottom: 12px;
343
+ }
344
+
345
+ .browser-tag {
346
+ font-size: 12px;
347
+ font-weight: 600;
348
+ color: #64748b;
349
+ text-transform: uppercase;
350
+ letter-spacing: 0.5px;
351
+ }
352
+
353
+ .status-indicator {
354
+ width: 12px;
355
+ height: 12px;
356
+ border-radius: 50%;
357
+ }
358
+
359
+ .status-indicator.passed {
360
+ background: #2a9c68;
361
+ box-shadow: 0 0 0 3px #ecfdf5;
362
+ }
363
+
364
+ .status-indicator.failed {
365
+ background: #ef4444;
366
+ box-shadow: 0 0 0 3px #fef2f2;
367
+ }
368
+
369
+ .status-indicator.skipped {
370
+ background: #f59e0b;
371
+ box-shadow: 0 0 0 3px #fffbeb;
372
+ }
373
+
374
+ .suite-card h3 {
375
+ font-size: 16px;
376
+ margin: 0 0 16px 0;
377
+ color: #1e293b;
378
+ white-space: nowrap;
379
+ overflow: hidden;
380
+ text-overflow: ellipsis;
381
+ }
382
+
383
+ .test-visualization {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 12px;
387
+ }
388
+
389
+ .test-dots {
390
+ padding: 4px;
391
+ display: flex;
392
+ flex-wrap: wrap;
393
+ gap: 6px;
394
+ flex-grow: 1;
395
+ }
396
+
397
+ .test-dot {
398
+ width: 10px;
399
+ height: 10px;
400
+ border-radius: 50%;
401
+ }
402
+
403
+ .test-dot.passed {
404
+ background: #2a9c68;
405
+ }
406
+
407
+ .test-dot.failed {
408
+ background: #ef4444;
409
+ }
410
+
411
+ .test-dot.skipped {
412
+ background: #f59e0b;
413
+ }
414
+
415
+ .test-count {
416
+ font-size: 14px;
417
+ color: #64748b;
418
+ min-width: 60px;
419
+ text-align: right;
420
+ }
421
+ </style>
422
+ </div>
423
+ `;
424
+ }
425
+
426
+ // Enhanced HTML generation with properly integrated CSS and JS
427
+ function generateHTML(reportData) {
428
+ const { run, results } = reportData;
429
+ const suitesData = getSuitesData(reportData.results);
430
+ const runSummary = run || {
431
+ totalTests: 0,
432
+ passed: 0,
433
+ failed: 0,
434
+ skipped: 0,
435
+ duration: 0,
436
+ timestamp: new Date(),
437
+ };
438
+
439
+ // Calculate additional metrics
440
+ const passPercentage =
441
+ runSummary.totalTests > 0
442
+ ? Math.round((runSummary.passed / runSummary.totalTests) * 100)
443
+ : 0;
444
+ const failPercentage =
445
+ runSummary.totalTests > 0
446
+ ? Math.round((runSummary.failed / runSummary.totalTests) * 100)
447
+ : 0;
448
+ const skipPercentage =
449
+ runSummary.totalTests > 0
450
+ ? Math.round((runSummary.skipped / runSummary.totalTests) * 100)
451
+ : 0;
452
+ const avgTestDuration =
453
+ runSummary.totalTests > 0
454
+ ? formatDuration(runSummary.duration / runSummary.totalTests)
455
+ : "0.0s";
456
+
457
+ // Generate test cases HTML
458
+ const generateTestCasesHTML = () => {
459
+ if (!results || results.length === 0) {
460
+ return '<div class="no-tests">No test results found</div>';
461
+ }
462
+
463
+ // Collect all unique tags and browsers
464
+ const allTags = new Set();
465
+ const allBrowsers = new Set();
466
+
467
+ results.forEach((test) => {
468
+ (test.tags || []).forEach((tag) => allTags.add(tag));
469
+ const browserMatch = test.name.match(/ > (\w+) > /);
470
+ if (browserMatch) allBrowsers.add(browserMatch[1]);
471
+ });
472
+
473
+ return results
474
+ .map((test, index) => {
475
+ const browserMatch = test.name.match(/ > (\w+) > /);
476
+ const browser = browserMatch ? browserMatch[1] : "unknown";
477
+ const testName = test.name.split(" > ").pop() || test.name;
478
+
479
+ // Generate steps HTML recursively
480
+ const generateStepsHTML = (steps, depth = 0) => {
481
+ if (!steps || steps.length === 0) return "";
482
+
483
+ return steps
484
+ .map((step) => {
485
+ const hasNestedSteps = step.steps && step.steps.length > 0;
486
+ const isHook = step.isHook;
487
+ const stepClass = isHook ? "step-hook" : "";
488
+ const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
489
+
490
+ return `
491
+ <div class="step-item" style="padding-left: ${depth * 20}px">
492
+ <div class="step-header ${stepClass}" onclick="toggleStepDetails(this)">
493
+ <span class="step-icon">${getStatusIcon(step.status)}</span>
494
+ <span class="step-title">${sanitizeHTML(
495
+ step.title
496
+ )}${hookIndicator}</span>
497
+ <span class="step-duration">${formatDuration(
498
+ step.duration
499
+ )}</span>
500
+ </div>
501
+ <div class="step-details">
502
+ ${
503
+ step.codeLocation
504
+ ? `<div><strong>Location:</strong> ${sanitizeHTML(
505
+ step.codeLocation
506
+ )}</div>`
507
+ : ""
508
+ }
509
+ ${
510
+ step.errorMessage
511
+ ? `
512
+ <div class="step-error">
513
+ <strong>Error:</strong> ${sanitizeHTML(step.errorMessage)}
514
+ ${
515
+ step.stackTrace
516
+ ? `<pre>${sanitizeHTML(step.stackTrace)}</pre>`
517
+ : ""
518
+ }
519
+ </div>
520
+ `
521
+ : ""
522
+ }
523
+ ${
524
+ hasNestedSteps
525
+ ? `
526
+ <div class="nested-steps">
527
+ ${generateStepsHTML(step.steps, depth + 1)}
528
+ </div>
529
+ `
530
+ : ""
531
+ }
532
+ </div>
533
+ </div>
534
+ `;
535
+ })
536
+ .join("");
537
+ };
538
+
539
+ return `
540
+ <div class="test-suite" data-status="${
541
+ test.status
542
+ }" data-browser="${browser}" data-tags="${(test.tags || []).join(",")}">
543
+ <div class="suite-header" onclick="toggleTestDetails(this)">
544
+ <div>
545
+ <span class="${getStatusClass(
546
+ test.status
547
+ )}">${test.status.toUpperCase()}</span>
548
+ <span class="test-name">${sanitizeHTML(testName)}</span>
549
+ <span class="test-browser">(${browser})</span>
550
+ </div>
551
+ <div class="test-meta">
552
+ <span class="test-duration">${formatDuration(
553
+ test.duration
554
+ )}</span>
555
+ </div>
556
+ </div>
557
+ <div class="suite-content">
558
+ <div class="test-details">
559
+ <h3>Test Details</h3>
560
+ <p><strong>Status:</strong> <span class="${getStatusClass(
561
+ test.status
562
+ )}">${test.status.toUpperCase()}</span></p>
563
+ <p><strong>Browser:</strong> ${browser}</p>
564
+ <p><strong>Duration:</strong> ${formatDuration(test.duration)}</p>
565
+ ${
566
+ test.tags && test.tags.length > 0
567
+ ? `<p><strong>Tags:</strong> ${test.tags
568
+ .map((t) => `<span class="tag">${t}</span>`)
569
+ .join(" ")}</p>`
570
+ : ""
571
+ }
572
+
573
+ <h3>Test Steps</h3>
574
+ <div class="steps-list">
575
+ ${generateStepsHTML(test.steps)}
576
+ </div>
577
+
578
+ ${
579
+ test.screenshots && test.screenshots.length > 0
580
+ ? `
581
+ <div class="attachments-section">
582
+ <h4>Screenshots</h4>
583
+ <div class="attachments-grid">
584
+ ${test.screenshots
585
+ .map(
586
+ (screenshot) => `
587
+ <div class="attachment-item">
588
+ <img src="${screenshot}" alt="Screenshot">
589
+ <div class="attachment-info">
590
+ <a href="${screenshot}" target="_blank">View Full Size</a>
591
+ </div>
592
+ </div>
593
+ `
594
+ )
595
+ .join("")}
596
+ </div>
597
+ </div>
598
+ `
599
+ : ""
600
+ }
601
+
602
+ ${
603
+ test.codeSnippet
604
+ ? `
605
+ <div class="code-section">
606
+ <h4>Code Snippet</h4>
607
+ <pre>${sanitizeHTML(test.codeSnippet)}</pre>
608
+ </div>
609
+ `
610
+ : ""
611
+ }
612
+ </div>
613
+ </div>
614
+ </div>
615
+ `;
616
+ })
617
+ .join("");
618
+ };
619
+
620
+ // Generate HTML with optimized CSS and JS
621
+ return `
622
+ <!DOCTYPE html>
623
+ <html lang="en">
624
+ <head>
625
+ <meta charset="UTF-8">
626
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
627
+ <title>Playwright Pulse Report</title>
628
+ <style>
629
+ /* Base Styles */
630
+ :root {
631
+ --primary-color: #3f51b5;
632
+ --secondary-color: #ff4081;
633
+ --success-color: #4CAF50;
634
+ --danger-color: #F44336;
635
+ --warning-color: #FFC107;
636
+ --info-color: #2196F3;
637
+ --light-color: #f5f5f5;
638
+ --dark-color: #212121;
639
+ --text-color: #424242;
640
+ --border-color: #e0e0e0;
641
+ }
642
+
643
+ body {
644
+ font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif;
645
+ margin: 0;
646
+ padding: 0;
647
+ background-color: #fafafa;
648
+ color: var(--text-color);
649
+ line-height: 1.6;
650
+ }
651
+
652
+ .container {
653
+ margin: 20px auto;
654
+ padding: 20px;
655
+ background-color: #fff;
656
+ border-radius: 8px;
657
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
658
+ }
659
+
660
+ /* Header Styles */
661
+ .header {
662
+ display: flex;
663
+ justify-content: space-between;
664
+ align-items: center;
665
+ flex-wrap: wrap;
666
+ margin-bottom: 20px;
667
+ padding-bottom: 20px;
668
+ border-bottom: 1px solid var(--border-color);
669
+ }
670
+
671
+ .header h1 {
672
+ margin: 0;
673
+ font-size: 24px;
674
+ color: var(--primary-color);
675
+ display: flex;
676
+ align-items: center;
677
+ gap: 10px;
678
+ }
679
+
680
+ .run-info {
681
+ background: #f5f5f5;
682
+ padding: 10px 15px;
683
+ border-radius: 6px;
684
+ font-size: 14px;
685
+ }
686
+
687
+ /* Tab Styles */
688
+ .tabs {
689
+ display: flex;
690
+ border-bottom: 1px solid var(--border-color);
691
+ margin-bottom: 20px;
692
+ }
693
+
694
+ .tab-button {
695
+ padding: 10px 20px;
696
+ background: none;
697
+ border: none;
698
+ cursor: pointer;
699
+ font-size: 16px;
700
+ color: #666;
701
+ position: relative;
702
+ }
703
+
704
+ .tab-button.active {
705
+ color: var(--primary-color);
706
+ font-weight: 500;
707
+ }
708
+
709
+ .tab-button.active::after {
710
+ content: '';
711
+ position: absolute;
712
+ bottom: -1px;
713
+ left: 0;
714
+ right: 0;
715
+ height: 2px;
716
+ background: var(--primary-color);
717
+ }
718
+
719
+ .tab-content {
720
+ display: none;
721
+ }
722
+
723
+ .tab-content.active {
724
+ display: block;
725
+ }
726
+
727
+ /* Main dashboard grid layout */
728
+ .dashboard-grid {
729
+ display: grid;
730
+ grid-template-columns: 1fr;
731
+ gap: 20px;
732
+ padding: 16px 0;
733
+ }
734
+
735
+ .summary-card {
736
+ background: linear-gradient(to bottom right, #f0f4ff, #ffffff);
737
+ border-radius: 8px;
738
+ padding: 20px;
739
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
740
+ text-align: center;
741
+ }
742
+
743
+ .summary-card h3 {
744
+ margin: 0 0 10px;
745
+ font-size: 16px;
746
+ color: #666;
747
+ }
748
+
749
+ .summary-card .value {
750
+ font-size: 28px;
751
+ font-weight: 600;
752
+ margin: 10px 0;
753
+ }
754
+
755
+ .status-passed .value {
756
+ color: var(--success-color);
757
+ }
758
+
759
+ .status-failed .value {
760
+ color: var(--danger-color);
761
+ }
762
+
763
+ .status-skipped .value {
764
+ color: var(--warning-color);
765
+ }
766
+
767
+ .pie-chart-container {
768
+ grid-column: span 2;
769
+ background: linear-gradient(to bottom right, #f0f4ff, #ffffff);
770
+ border-radius: 8px;
771
+ padding: 20px;
772
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
773
+ }
774
+
775
+ /* Test Run Summary Styles */
776
+ .filters {
777
+ display: flex;
778
+ gap: 10px;
779
+ margin-bottom: 20px;
780
+ flex-wrap: wrap;
781
+ }
782
+
783
+ .filters input,
784
+ .filters select {
785
+ padding: 8px 12px;
786
+ border: 1px solid #ddd;
787
+ border-radius: 4px;
788
+ font-size: 14px;
789
+ }
790
+
791
+ .filters button {
792
+ padding: 8px 16px;
793
+ background: var(--primary-color);
794
+ color: white;
795
+ border: none;
796
+ border-radius: 4px;
797
+ cursor: pointer;
798
+ }
799
+
800
+ .test-suite {
801
+ margin-bottom: 15px;
802
+ border: 1px solid #eee;
803
+ border-radius: 6px;
804
+ overflow: hidden;
805
+ }
806
+
807
+ .suite-header {
808
+ padding: 12px 15px;
809
+ background: #f9f9f9;
810
+ cursor: pointer;
811
+ display: flex;
812
+ justify-content: space-between;
813
+ align-items: center;
814
+ }
815
+
816
+ .suite-header:hover {
817
+ background: #f0f0f0;
818
+ }
819
+
820
+ .suite-content {
821
+ display: none;
822
+ padding: 15px;
823
+ background: white;
824
+ }
825
+
826
+ .test-details h3 {
827
+ margin-top: 0;
828
+ font-size: 18px;
829
+ color: var(--dark-color);
830
+ }
831
+
832
+ .steps-list {
833
+ margin: 15px 0;
834
+ padding: 0;
835
+ list-style: none;
836
+ }
837
+
838
+ .step-item {
839
+ margin-bottom: 8px;
840
+ }
841
+
842
+ .step-header {
843
+ display: flex;
844
+ align-items: center;
845
+ cursor: pointer;
846
+ padding: 8px;
847
+ border-radius: 4px;
848
+ }
849
+
850
+ .step-header:hover {
851
+ background: #f5f5f5;
852
+ }
853
+
854
+ .step-icon {
855
+ margin-right: 8px;
856
+ width: 20px;
857
+ text-align: center;
858
+ }
859
+
860
+ .step-title {
861
+ flex: 1;
862
+ }
863
+
864
+ .step-duration {
865
+ color: #666;
866
+ font-size: 12px;
867
+ }
868
+
869
+ .step-details {
870
+ display: none;
871
+ padding: 10px;
872
+ margin-top: 5px;
873
+ background: #f9f9f9;
874
+ border-radius: 4px;
875
+ font-size: 14px;
876
+ }
877
+
878
+ .step-error {
879
+ color: var(--danger-color);
880
+ margin-top: 8px;
881
+ padding: 8px;
882
+ background: rgba(244, 67, 54, 0.1);
883
+ border-radius: 4px;
884
+ font-size: 13px;
885
+ }
886
+
887
+ .step-hook {
888
+ background: rgba(33, 150, 243, 0.1);
889
+ }
890
+
891
+ .nested-steps {
892
+ display: none;
893
+ padding-left: 20px;
894
+ border-left: 2px solid #eee;
895
+ margin-top: 8px;
896
+ }
897
+
898
+ .attachments-grid {
899
+ display: grid;
900
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
901
+ gap: 15px;
902
+ margin-top: 15px;
903
+ }
904
+
905
+ .attachment-item {
906
+ border: 1px solid #eee;
907
+ border-radius: 4px;
908
+ overflow: hidden;
909
+ }
910
+
911
+ .attachment-item img {
912
+ width: 100%;
913
+ height: auto;
914
+ display: block;
915
+ }
916
+
917
+ .tag {
918
+ display: inline-block;
919
+ background: #e0e0e0;
920
+ padding: 2px 6px;
921
+ border-radius: 4px;
922
+ font-size: 12px;
923
+ margin-right: 5px;
924
+ }
925
+ .status-badge {
926
+ padding: 3px 8px;
927
+ border-radius: 4px;
928
+ font-size: 12px;
929
+ font-weight: bold;
930
+ color: white;
931
+ text-transform: uppercase;
932
+ }
933
+
934
+ span.status-passed {
935
+ background-color: #4CAF50 !important; /* Bright green */
936
+ color: white;
937
+ border-radius: 4px;
938
+ padding: 4px
939
+ }
940
+
941
+ span.status-failed {
942
+ background-color: #F44336 !important; /* Bright red */
943
+ color: white;
944
+ border-radius: 4px;
945
+ padding: 4px
946
+ }
947
+
948
+ span.status-skipped {
949
+ background-color: #FFC107 !important; /* Deep yellow */
950
+ color: white;
951
+ border-radius: 4px;
952
+ padding: 4px
953
+ }
954
+
955
+ /* Enhanced Pie Chart Styles */
956
+ .pie-chart-container {
957
+ display: flex;
958
+ flex-direction: column;
959
+ align-items: center;
960
+ margin: 20px 0;
961
+ }
962
+
963
+ .pie-chart-svg {
964
+ margin: 0 auto;
965
+ }
966
+
967
+ .pie-chart-total {
968
+ font-size: 18px;
969
+ font-weight: bold;
970
+ fill: #333;
971
+ }
972
+
973
+ .pie-chart-label {
974
+ font-size: 12px;
975
+ fill: #666;
976
+ }
977
+
978
+ .pie-chart-legend {
979
+ display: flex;
980
+ flex-wrap: wrap;
981
+ justify-content: center;
982
+ gap: 15px;
983
+ margin-top: 15px;
984
+ }
985
+
986
+ .legend-item {
987
+ display: flex;
988
+ align-items: center;
989
+ gap: 5px;
990
+ font-size: 14px;
991
+ }
992
+
993
+ .legend-color {
994
+ width: 12px;
995
+ height: 12px;
996
+ border-radius: 50%;
997
+ display: inline-block;
998
+ }
999
+
1000
+ .legend-value {
1001
+ font-weight: 500;
1002
+ }
1003
+ .test-name {
1004
+ font-weight: 600;
1005
+ }
1006
+
1007
+ /* Below summary cards: chart and test suites */
1008
+ .dashboard-bottom {
1009
+ display: flex;
1010
+ flex-direction: column;
1011
+ gap: 24px;
1012
+ }
1013
+ /* Responsive Styles */
1014
+ /* Mobile (up to 480px) and Tablet (481px to 768px) Responsive Styles */
1015
+
1016
+ @media (min-width: 768px) {
1017
+ .dashboard-grid {
1018
+ grid-template-columns: repeat(4, 1fr); /* Four summary cards side-by-side */
1019
+ }
1020
+ .dashboard-bottom {
1021
+ flex-direction: row;
1022
+ }
1023
+ .test-distribution {
1024
+ flex: 1;
1025
+ }
1026
+ .test-suites {
1027
+ flex: 2; /* dynamically expand */
1028
+ min-width: 300px;
1029
+ }
1030
+ }
1031
+ @media (max-width: 768px) {
1032
+ /* Base container adjustments */
1033
+ .container {
1034
+ padding: 15px;
1035
+ margin: 10px auto;
1036
+ }
1037
+
1038
+ /* Header adjustments */
1039
+ .header {
1040
+ flex-direction: column;
1041
+ align-items: flex-start;
1042
+ gap: 15px;
1043
+ padding-bottom: 15px;
1044
+ }
1045
+
1046
+ .run-info {
1047
+ font-size: 13px;
1048
+ }
1049
+
1050
+ /* Tab adjustments */
1051
+ .tabs {
1052
+ overflow-x: auto;
1053
+ white-space: nowrap;
1054
+ padding-bottom: 5px;
1055
+ }
1056
+
1057
+ .tab-button {
1058
+ padding: 8px 15px;
1059
+ font-size: 14px;
1060
+ }
1061
+
1062
+ /* Dashboard Grid adjustments */
1063
+ .dashboard-grid {
1064
+ grid-template-columns: 1fr;
1065
+ gap: 15px;
1066
+ }
1067
+
1068
+ .summary-card {
1069
+ padding: 15px;
1070
+ }
1071
+
1072
+ .summary-card .value {
1073
+ font-size: 24px;
1074
+ }
1075
+
1076
+ .pie-chart-container {
1077
+ padding: 15px;
1078
+ }
1079
+
1080
+ .pie-chart-container svg {
1081
+ width: 300px;
1082
+ height: 300px;
1083
+ }
1084
+
1085
+ /* Test Suites Widget adjustments */
1086
+ .suites-widget {
1087
+ padding: 8px;
1088
+ }
1089
+
1090
+ .suites-header {
1091
+ flex-direction: column;
1092
+ align-items: flex-start;
1093
+ gap: 10px;
1094
+ }
1095
+
1096
+ .suites-grid {
1097
+ grid-template-columns: 1fr;
1098
+ }
1099
+
1100
+ /* Test Run Summary adjustments */
1101
+ .filters {
1102
+ flex-direction: column;
1103
+ gap: 8px;
1104
+ }
1105
+
1106
+ .filters input,
1107
+ .filters select {
1108
+ width: 100%;
1109
+ padding: 8px;
1110
+ }
1111
+
1112
+ .filters button {
1113
+ width: 100%;
1114
+ margin-top: 5px;
1115
+ }
1116
+
1117
+ .test-suite {
1118
+ margin-bottom: 10px;
1119
+ }
1120
+
1121
+ .suite-header {
1122
+ padding: 10px;
1123
+ flex-wrap: wrap;
1124
+ }
1125
+
1126
+ .test-name {
1127
+ display: block;
1128
+ width: 100%;
1129
+ margin-top: 5px;
1130
+ font-weight: 600;
1131
+ }
1132
+
1133
+ .test-meta {
1134
+ margin-top: 5px;
1135
+ }
1136
+
1137
+ .suite-content {
1138
+ padding: 10px;
1139
+ }
1140
+
1141
+ .steps-list {
1142
+ margin: 10px 0;
1143
+ }
1144
+
1145
+ .step-header {
1146
+ padding: 6px;
1147
+ }
1148
+
1149
+ .step-icon {
1150
+ font-size: 14px;
1151
+ }
1152
+
1153
+ .step-title {
1154
+ font-size: 14px;
1155
+ }
1156
+
1157
+ .step-duration {
1158
+ font-size: 11px;
1159
+ }
1160
+
1161
+ .attachments-grid {
1162
+ grid-template-columns: repeat(2, 1fr);
1163
+ }
1164
+
1165
+ /* Specific adjustments for mobile only (up to 480px) */
1166
+ @media (max-width: 480px) {
1167
+ .header h1 {
1168
+ font-size: 20px;
1169
+ }
1170
+
1171
+ .summary-card .value {
1172
+ font-size: 22px;
1173
+ }
1174
+ .pie-chart-container {
1175
+ grid-column: span 1;
1176
+ }
1177
+ .pie-chart-container svg {
1178
+ width: 300px;
1179
+ height: 300px;
1180
+ }
1181
+
1182
+ .attachments-grid {
1183
+ grid-template-columns: 1fr;
1184
+ }
1185
+
1186
+ .step-item {
1187
+ padding-left: 0 !important;
1188
+ }
1189
+
1190
+ .nested-steps {
1191
+ padding-left: 10px;
1192
+ }
1193
+ }
1194
+ }
1195
+ </style>
1196
+ </head>
1197
+ <body>
1198
+ <div class="container">
1199
+ <header class="header">
1200
+ <div style="display: flex; align-items: center; gap: 15px;">
1201
+ <img id="report-logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA3bDggNSA4LTUtOC01eiIgZmlsbD0iIzNmNTEiLz48cGF0aCBkPSJNMTIgNkw0IDExbDggNSA4LTUtOC01eiIgZmlsbD0iIzQyODVmNCIvPjxwYXRoIGQ9Ik0xMiAxMGwtOCA1IDggNSA4LTUtOC01eiIgZmlsbD0iIzNkNTViNCIvPjwvc3ZnPg==" alt="Logo" style="height: 40px;">
1202
+ <h1>
1203
+ Playwright Pulse Report
1204
+ </h1>
1205
+ </div>
1206
+ <div class="run-info">
1207
+ <strong>Run Date:</strong> ${formatDate(
1208
+ runSummary.timestamp
1209
+ )}<br>
1210
+ <strong>Total Duration:</strong> ${formatDuration(
1211
+ runSummary.duration
1212
+ )}
1213
+ </div>
1214
+ </header>
1215
+
1216
+ <div class="tabs">
1217
+ <button class="tab-button active" data-tab="dashboard">Dashboard</button>
1218
+ <button class="tab-button" data-tab="test-runs">Test Run Summary</button>
1219
+ </div>
1220
+
1221
+ <div id="dashboard" class="tab-content active">
1222
+ <div class="dashboard-grid">
1223
+ <div class="summary-card">
1224
+ <h3>Total Tests</h3>
1225
+ <div class="value">${runSummary.totalTests}</div>
1226
+ </div>
1227
+ <div class="summary-card status-passed">
1228
+ <h3>Passed</h3>
1229
+ <div class="value">${runSummary.passed}</div>
1230
+ <div class="trend">${passPercentage}%</div>
1231
+ </div>
1232
+ <div class="summary-card status-failed">
1233
+ <h3>Failed</h3>
1234
+ <div class="value">${runSummary.failed}</div>
1235
+ <div class="trend">${failPercentage}%</div>
1236
+ </div>
1237
+ <div class="summary-card status-skipped">
1238
+ <h3>Skipped</h3>
1239
+ <div class="value">${runSummary.skipped}</div>
1240
+ <div class="trend">${skipPercentage}%</div>
1241
+ </div>
1242
+ </div>
1243
+ <div class="dashboard-bottom">
1244
+ ${generatePieChartD3([
1245
+ { label: "Passed", value: runSummary.passed },
1246
+ { label: "Failed", value: runSummary.failed },
1247
+ { label: "Skipped", value: runSummary.skipped },
1248
+ ])}
1249
+ ${generateSuitesWidget(suitesData)}
1250
+ <div class="summary-cards">
1251
+ <div class="summary-card avg-time">
1252
+ <h3>Avg. Time</h3>
1253
+ <div class="value">${avgTestDuration}</div>
1254
+ </div>
1255
+ <div class="summary-card avg-time">
1256
+ <h3>Total Time</h3>
1257
+ <div class="value">${formatDuration(
1258
+ runSummary.duration
1259
+ )}</div>
1260
+ </div>
1261
+ </div>
1262
+ </div>
1263
+ </div>
1264
+ </div>
1265
+
1266
+ <div id="test-runs" class="tab-content">
1267
+ <div class="filters">
1268
+ <input type="text" id="filter-name" placeholder="Search by test name...">
1269
+ <select id="filter-status">
1270
+ <option value="">All Statuses</option>
1271
+ <option value="passed">Passed</option>
1272
+ <option value="failed">Failed</option>
1273
+ <option value="skipped">Skipped</option>
1274
+ </select>
1275
+ <select id="filter-browser">
1276
+ <option value="">All Browsers</option>
1277
+ ${Array.from(
1278
+ new Set(
1279
+ results.map((test) => {
1280
+ const match = test.name.match(/ > (\w+) > /);
1281
+ return match ? match[1] : "unknown";
1282
+ })
1283
+ )
1284
+ )
1285
+ .map(
1286
+ (browser) => `
1287
+ <option value="${browser}">${browser}</option>
1288
+ `
1289
+ )
1290
+ .join("")}
1291
+ </select>
1292
+ <button onclick="expandAllTests()">Expand All</button>
1293
+ <button onclick="collapseAllTests()">Collapse All</button>
1294
+ </div>
1295
+ <div class="test-suites">
1296
+ ${generateTestCasesHTML()}
1297
+ </div>
1298
+ </div>
1299
+ </div>
1300
+
1301
+ <script>
1302
+ // Tab switching functionality
1303
+ document.querySelectorAll('.tab-button').forEach(button => {
1304
+ button.addEventListener('click', () => {
1305
+ // Remove active class from all buttons and contents
1306
+ document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
1307
+ document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
1308
+
1309
+ // Add active class to clicked button and corresponding content
1310
+ const tabId = button.getAttribute('data-tab');
1311
+ button.classList.add('active');
1312
+ document.getElementById(tabId).classList.add('active');
1313
+ });
1314
+ });
1315
+
1316
+ // Test filtering functionality
1317
+ function setupFilters() {
1318
+ const nameFilter = document.getElementById('filter-name');
1319
+ const statusFilter = document.getElementById('filter-status');
1320
+ const browserFilter = document.getElementById('filter-browser');
1321
+
1322
+ const filterTests = () => {
1323
+ const nameValue = nameFilter.value.toLowerCase();
1324
+ const statusValue = statusFilter.value;
1325
+ const browserValue = browserFilter.value;
1326
+
1327
+ document.querySelectorAll('.test-suite').forEach(suite => {
1328
+ const name = suite.querySelector('.test-name').textContent.toLowerCase();
1329
+ const status = suite.getAttribute('data-status');
1330
+ const browser = suite.getAttribute('data-browser');
1331
+
1332
+ const nameMatch = name.includes(nameValue);
1333
+ const statusMatch = !statusValue || status === statusValue;
1334
+ const browserMatch = !browserValue || browser === browserValue;
1335
+
1336
+ if (nameMatch && statusMatch && browserMatch) {
1337
+ suite.style.display = 'block';
1338
+ } else {
1339
+ suite.style.display = 'none';
1340
+ }
1341
+ });
1342
+ };
1343
+
1344
+ nameFilter.addEventListener('input', filterTests);
1345
+ statusFilter.addEventListener('change', filterTests);
1346
+ browserFilter.addEventListener('change', filterTests);
1347
+ }
1348
+
1349
+ // Test expansion functionality
1350
+ function toggleTestDetails(header) {
1351
+ const content = header.nextElementSibling;
1352
+ content.style.display = content.style.display === 'block' ? 'none' : 'block';
1353
+ }
1354
+
1355
+ // Step expansion functionality
1356
+ function toggleStepDetails(header) {
1357
+ const details = header.nextElementSibling;
1358
+ details.style.display = details.style.display === 'block' ? 'none' : 'block';
1359
+
1360
+ // Toggle nested steps if they exist
1361
+ const nestedSteps = header.parentElement.querySelector('.nested-steps');
1362
+ if (nestedSteps) {
1363
+ nestedSteps.style.display = nestedSteps.style.display === 'block' ? 'none' : 'block';
1364
+ }
1365
+ }
1366
+
1367
+ // Expand all tests
1368
+ function expandAllTests() {
1369
+ document.querySelectorAll('.suite-content').forEach(el => {
1370
+ el.style.display = 'block';
1371
+ });
1372
+ document.querySelectorAll('.step-details').forEach(el => {
1373
+ el.style.display = 'block';
1374
+ });
1375
+ document.querySelectorAll('.nested-steps').forEach(el => {
1376
+ el.style.display = 'block';
1377
+ });
1378
+ }
1379
+
1380
+ // Collapse all tests
1381
+ function collapseAllTests() {
1382
+ document.querySelectorAll('.suite-content').forEach(el => {
1383
+ el.style.display = 'none';
1384
+ });
1385
+ document.querySelectorAll('.step-details').forEach(el => {
1386
+ el.style.display = 'none';
1387
+ });
1388
+ document.querySelectorAll('.nested-steps').forEach(el => {
1389
+ el.style.display = 'none';
1390
+ });
1391
+ }
1392
+
1393
+ // Initialize everything when DOM is loaded
1394
+ document.addEventListener('DOMContentLoaded', () => {
1395
+ setupFilters();
1396
+
1397
+ // Make step headers clickable
1398
+ document.querySelectorAll('.step-header').forEach(header => {
1399
+ header.addEventListener('click', function() {
1400
+ toggleStepDetails(this);
1401
+ });
1402
+ });
1403
+
1404
+ // Make test headers clickable
1405
+ document.querySelectorAll('.suite-header').forEach(header => {
1406
+ header.addEventListener('click', function() {
1407
+ toggleTestDetails(this);
1408
+ });
1409
+ });
1410
+ });
1411
+
1412
+ // Enhanced expand/collapse functionality
1413
+ function toggleTestDetails(header) {
1414
+ const content = header.nextElementSibling;
1415
+ const isExpanded = content.style.display === 'block';
1416
+ content.style.display = isExpanded ? 'none' : 'block';
1417
+ header.setAttribute('aria-expanded', !isExpanded);
1418
+ }
1419
+
1420
+ function toggleStepDetails(header) {
1421
+ const details = header.nextElementSibling;
1422
+ const nestedSteps = header.parentElement.querySelector('.nested-steps');
1423
+
1424
+ // Toggle main step details
1425
+ const isExpanded = details.style.display === 'block';
1426
+ details.style.display = isExpanded ? 'none' : 'block';
1427
+
1428
+ // Toggle nested steps if they exist
1429
+ if (nestedSteps) {
1430
+ nestedSteps.style.display = isExpanded ? 'none' : 'block';
1431
+ }
1432
+
1433
+ header.setAttribute('aria-expanded', !isExpanded);
1434
+ }
1435
+
1436
+ function expandAllTests() {
1437
+ document.querySelectorAll('.suite-content').forEach(el => {
1438
+ el.style.display = 'block';
1439
+ });
1440
+ document.querySelectorAll('.step-details').forEach(el => {
1441
+ el.style.display = 'block';
1442
+ });
1443
+ document.querySelectorAll('.nested-steps').forEach(el => {
1444
+ el.style.display = 'block';
1445
+ });
1446
+ document.querySelectorAll('[aria-expanded]').forEach(el => {
1447
+ el.setAttribute('aria-expanded', 'true');
1448
+ });
1449
+ }
1450
+
1451
+ function collapseAllTests() {
1452
+ document.querySelectorAll('.suite-content').forEach(el => {
1453
+ el.style.display = 'none';
1454
+ });
1455
+ document.querySelectorAll('.step-details').forEach(el => {
1456
+ el.style.display = 'none';
1457
+ });
1458
+ document.querySelectorAll('.nested-steps').forEach(el => {
1459
+ el.style.display = 'none';
1460
+ });
1461
+ document.querySelectorAll('[aria-expanded]').forEach(el => {
1462
+ el.setAttribute('aria-expanded', 'false');
1463
+ });
1464
+ }
1465
+
1466
+ // Initialize all interactive elements
1467
+ function initializeInteractiveElements() {
1468
+ // Test headers
1469
+ document.querySelectorAll('.suite-header').forEach(header => {
1470
+ header.addEventListener('click', () => toggleTestDetails(header));
1471
+ header.setAttribute('role', 'button');
1472
+ header.setAttribute('aria-expanded', 'false');
1473
+ });
1474
+
1475
+ // Step headers
1476
+ document.querySelectorAll('.step-header').forEach(header => {
1477
+ header.addEventListener('click', () => toggleStepDetails(header));
1478
+ header.setAttribute('role', 'button');
1479
+ header.setAttribute('aria-expanded', 'false');
1480
+ });
1481
+
1482
+ // Filter buttons
1483
+ document.getElementById('filter-name').addEventListener('input', filterTests);
1484
+ document.getElementById('filter-status').addEventListener('change', filterTests);
1485
+ document.getElementById('filter-browser').addEventListener('change', filterTests);
1486
+ }
1487
+
1488
+ // Initialize when DOM is loaded
1489
+ document.addEventListener('DOMContentLoaded', initializeInteractiveElements);
1490
+ </script>
1491
+ </body>
1492
+ </html>
1493
+ `;
1494
+ }
1495
+
1496
+ // [Keep the rest of the file unchanged...]
1497
+
1498
+ // [Rest of the file remains the same...]
1499
+
1500
+ // Main execution function
1501
+ async function main() {
1502
+ const outputDir = path.resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
1503
+ const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE);
1504
+ const reportHtmlPath = path.resolve(outputDir, DEFAULT_HTML_FILE);
1505
+
1506
+ console.log(chalk.blue(`Generating enhanced static report in: ${outputDir}`));
1507
+
1508
+ let reportData;
1509
+ try {
1510
+ const jsonData = await fs.readFile(reportJsonPath, "utf-8");
1511
+ reportData = JSON.parse(jsonData);
1512
+ if (
1513
+ !reportData ||
1514
+ typeof reportData !== "object" ||
1515
+ !Array.isArray(reportData.results)
1516
+ ) {
1517
+ throw new Error("Invalid report JSON structure.");
1518
+ }
1519
+ } catch (error) {
1520
+ console.error(chalk.red(`Error: ${error.message}`));
1521
+ process.exit(1);
1522
+ }
1523
+
1524
+ try {
1525
+ const htmlContent = generateHTML(reportData);
1526
+ await fs.writeFile(reportHtmlPath, htmlContent, "utf-8");
1527
+ console.log(
1528
+ chalk.green(`Report generated successfully at: ${reportHtmlPath}`)
1529
+ );
1530
+ console.log(chalk.blue(`You can open it in your browser with:`));
1531
+ console.log(chalk.blue(`open ${reportHtmlPath}`));
1532
+ } catch (error) {
1533
+ console.error(chalk.red(`Error: ${error.message}`));
1534
+ process.exit(1);
1535
+ }
1536
+ }
1537
+
1538
+ // Run the main function
1539
+ main();