@arghajit/dummy 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.
Files changed (33) hide show
  1. package/README.md +259 -0
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.js +26 -0
  4. package/dist/lib/report-types.d.ts +8 -0
  5. package/dist/lib/report-types.js +2 -0
  6. package/dist/playwright-pulse-reporter.d.ts +26 -0
  7. package/dist/playwright-pulse-reporter.js +304 -0
  8. package/dist/reporter/attachment-utils.d.ts +10 -0
  9. package/dist/reporter/attachment-utils.js +192 -0
  10. package/dist/reporter/index.d.ts +5 -0
  11. package/dist/reporter/index.js +9 -0
  12. package/dist/reporter/lib/report-types.d.ts +8 -0
  13. package/dist/reporter/lib/report-types.js +2 -0
  14. package/dist/reporter/playwright-pulse-reporter.d.ts +27 -0
  15. package/dist/reporter/playwright-pulse-reporter.js +454 -0
  16. package/dist/reporter/reporter/playwright-pulse-reporter.d.ts +1 -0
  17. package/dist/reporter/reporter/playwright-pulse-reporter.js +398 -0
  18. package/dist/reporter/types/index.d.ts +52 -0
  19. package/dist/reporter/types/index.js +2 -0
  20. package/dist/types/index.d.ts +65 -0
  21. package/dist/types/index.js +2 -0
  22. package/package.json +73 -0
  23. package/screenshots/127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max-1.png +0 -0
  24. package/screenshots/127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max.png +0 -0
  25. package/screenshots/Email-report.jpg +0 -0
  26. package/screenshots/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-1.png +0 -0
  27. package/screenshots/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-2.png +0 -0
  28. package/screenshots/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html.png +0 -0
  29. package/screenshots/image.png +0 -0
  30. package/scripts/generate-static-report.mjs +2279 -0
  31. package/scripts/generate-trend.mjs +165 -0
  32. package/scripts/merge-pulse-report.js +81 -0
  33. package/scripts/sendReport.js +335 -0
@@ -0,0 +1,2279 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as fs from "fs/promises";
4
+ import { readFileSync, existsSync as fsExistsSync } from "fs"; // ADD THIS LINE
5
+ import path from "path";
6
+ import { fork } from "child_process"; // Add this
7
+ import { fileURLToPath } from "url"; // Add this for resolving path in ESM
8
+
9
+ // Use dynamic import for chalk as it's ESM only
10
+ let chalk;
11
+ try {
12
+ chalk = (await import("chalk")).default;
13
+ } catch (e) {
14
+ console.warn("Chalk could not be imported. Using plain console logs.");
15
+ chalk = {
16
+ green: (text) => text,
17
+ red: (text) => text,
18
+ yellow: (text) => text,
19
+ blue: (text) => text,
20
+ bold: (text) => text,
21
+ gray: (text) => text,
22
+ };
23
+ }
24
+
25
+ // Default configuration
26
+ const DEFAULT_OUTPUT_DIR = "pulse-report";
27
+ const DEFAULT_JSON_FILE = "playwright-pulse-report.json";
28
+ const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
29
+
30
+ // Helper functions
31
+ function sanitizeHTML(str) {
32
+ if (str === null || str === undefined) return "";
33
+ return String(str).replace(/[&<>"']/g, (match) => {
34
+ const replacements = {
35
+ "&": "&",
36
+ "<": "<",
37
+ ">": ">",
38
+ '"': '"',
39
+ "'": "'", // or '
40
+ };
41
+ return replacements[match] || match;
42
+ });
43
+ }
44
+ function capitalize(str) {
45
+ if (!str) return ""; // Handle empty string
46
+ return str[0].toUpperCase() + str.slice(1).toLowerCase();
47
+ }
48
+
49
+ function formatPlaywrightError(error) {
50
+ // Get the error message and clean ANSI codes
51
+ const rawMessage = error.stack || error.message || error.toString();
52
+ const cleanMessage = rawMessage.replace(/\x1B\[[0-9;]*[mGKH]/g, "");
53
+
54
+ // Parse error components
55
+ const timeoutMatch = cleanMessage.match(/Timed out (\d+)ms waiting for/);
56
+ const assertionMatch = cleanMessage.match(/expect\((.*?)\)\.(.*?)\(/);
57
+ const expectedMatch = cleanMessage.match(
58
+ /Expected (?:pattern|string|regexp|value): (.*?)(?:\n|Call log|$)/s
59
+ );
60
+ const actualMatch = cleanMessage.match(
61
+ /Received (?:string|value): (.*?)(?:\n|Call log|$)/s
62
+ );
63
+
64
+ // HTML escape function
65
+ const escapeHtml = (str) => {
66
+ if (!str) return "";
67
+ return str.replace(
68
+ /[&<>'"]/g,
69
+ (tag) =>
70
+ ({
71
+ "&": "&amp;",
72
+ "<": "&lt;",
73
+ ">": "&gt;",
74
+ "'": "&#39;",
75
+ '"': "&quot;",
76
+ }[tag])
77
+ );
78
+ };
79
+
80
+ // Build HTML output
81
+ let html = `<div class="playwright-error">
82
+ <div class="error-header">Test Error</div>`;
83
+
84
+ if (timeoutMatch) {
85
+ html += `<div class="error-timeout">⏱ Timeout: ${escapeHtml(
86
+ timeoutMatch[1]
87
+ )}ms</div>`;
88
+ }
89
+
90
+ if (assertionMatch) {
91
+ html += `<div class="error-assertion">🔍 Assertion: expect(${escapeHtml(
92
+ assertionMatch[1]
93
+ )}).${escapeHtml(assertionMatch[2])}()</div>`;
94
+ }
95
+
96
+ if (expectedMatch) {
97
+ html += `<div class="error-expected">✅ Expected: ${escapeHtml(
98
+ expectedMatch[1]
99
+ )}</div>`;
100
+ }
101
+
102
+ if (actualMatch) {
103
+ html += `<div class="error-actual">❌ Actual: ${escapeHtml(
104
+ actualMatch[1]
105
+ )}</div>`;
106
+ }
107
+
108
+ // Add call log if present
109
+ const callLogStart = cleanMessage.indexOf("Call log:");
110
+ if (callLogStart !== -1) {
111
+ const callLogEnd =
112
+ cleanMessage.indexOf("\n\n", callLogStart) || cleanMessage.length;
113
+ const callLogSection = cleanMessage
114
+ .slice(callLogStart + 9, callLogEnd)
115
+ .trim();
116
+
117
+ html += `<div class="error-call-log">
118
+ <div class="call-log-header">📜 Call Log:</div>
119
+ <ul class="call-log-items">${callLogSection
120
+ .split("\n")
121
+ .map((line) => line.trim())
122
+ .filter((line) => line)
123
+ .map((line) => `<li>${escapeHtml(line.replace(/^-\s*/, ""))}</li>`)
124
+ .join("")}</ul>
125
+ </div>`;
126
+ }
127
+
128
+ // Add stack trace if present
129
+ const stackTraceMatch = cleanMessage.match(/\n\s*at\s.*/gs);
130
+ if (stackTraceMatch) {
131
+ html += `<div class="error-stack-trace">
132
+ <div class="stack-trace-header">🔎 Stack Trace:</div>
133
+ <ul class="stack-trace-items">${stackTraceMatch[0]
134
+ .trim()
135
+ .split("\n")
136
+ .map((line) => line.trim())
137
+ .filter((line) => line)
138
+ .map((line) => `<li>${escapeHtml(line)}</li>`)
139
+ .join("")}</ul>
140
+ </div>`;
141
+ }
142
+
143
+ html += `</div>`;
144
+
145
+ return html;
146
+ }
147
+
148
+ // User-provided formatDuration function
149
+ function formatDuration(ms) {
150
+ if (ms === undefined || ms === null || ms < 0) return "0.0s";
151
+ return (ms / 1000).toFixed(1) + "s";
152
+ }
153
+
154
+ function generateTestTrendsChart(trendData) {
155
+ if (!trendData || !trendData.overall || trendData.overall.length === 0) {
156
+ return '<div class="no-data">No overall trend data available for test counts.</div>';
157
+ }
158
+
159
+ const chartId = `testTrendsChart-${Date.now()}-${Math.random()
160
+ .toString(36)
161
+ .substring(2, 7)}`;
162
+ const runs = trendData.overall;
163
+
164
+ const series = [
165
+ {
166
+ name: "Total",
167
+ data: runs.map((r) => r.totalTests),
168
+ color: "var(--primary-color)", // Blue
169
+ marker: { symbol: "circle" },
170
+ },
171
+ {
172
+ name: "Passed",
173
+ data: runs.map((r) => r.passed),
174
+ color: "var(--success-color)", // Green
175
+ marker: { symbol: "circle" },
176
+ },
177
+ {
178
+ name: "Failed",
179
+ data: runs.map((r) => r.failed),
180
+ color: "var(--danger-color)", // Red
181
+ marker: { symbol: "circle" },
182
+ },
183
+ {
184
+ name: "Skipped",
185
+ data: runs.map((r) => r.skipped || 0),
186
+ color: "var(--warning-color)", // Yellow
187
+ marker: { symbol: "circle" },
188
+ },
189
+ ];
190
+
191
+ // Data needed by the tooltip formatter, stringified to be embedded in the client-side script
192
+ const runsForTooltip = runs.map((r) => ({
193
+ runId: r.runId,
194
+ timestamp: r.timestamp,
195
+ duration: r.duration,
196
+ }));
197
+
198
+ const optionsObjectString = `
199
+ {
200
+ chart: { type: "line", height: 350, backgroundColor: "transparent" },
201
+ title: { text: null },
202
+ xAxis: {
203
+ categories: ${JSON.stringify(runs.map((run, i) => `Run ${i + 1}`))},
204
+ crosshair: true,
205
+ labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}
206
+ },
207
+ yAxis: {
208
+ title: { text: "Test Count", style: { color: 'var(--text-color)'} },
209
+ min: 0,
210
+ labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}
211
+ },
212
+ legend: {
213
+ layout: "horizontal", align: "center", verticalAlign: "bottom",
214
+ itemStyle: { fontSize: "12px", color: 'var(--text-color)' }
215
+ },
216
+ plotOptions: {
217
+ series: { marker: { radius: 4, states: { hover: { radius: 6 }}}, states: { hover: { halo: { size: 5, opacity: 0.1 }}}},
218
+ line: { lineWidth: 2.5 } // fillOpacity was 0.1, but for line charts, area fill is usually separate (area chart type)
219
+ },
220
+ tooltip: {
221
+ shared: true, useHTML: true,
222
+ backgroundColor: 'rgba(10,10,10,0.92)',
223
+ borderColor: 'rgba(10,10,10,0.92)',
224
+ style: { color: '#f5f5f5' },
225
+ formatter: function () {
226
+ const runsData = ${JSON.stringify(runsForTooltip)};
227
+ const pointIndex = this.points[0].point.x; // Get index from point
228
+ const run = runsData[pointIndex];
229
+ let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' +
230
+ 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br><br>';
231
+ this.points.forEach(point => {
232
+ tooltip += '<span style="color:' + point.color + '">●</span> ' + point.series.name + ': <b>' + point.y + '</b><br>';
233
+ });
234
+ tooltip += '<br>Duration: ' + formatDuration(run.duration);
235
+ return tooltip;
236
+ }
237
+ },
238
+ series: ${JSON.stringify(series)},
239
+ credits: { enabled: false }
240
+ }
241
+ `;
242
+
243
+ return `
244
+ <div id="${chartId}" class="trend-chart-container"></div>
245
+ <script>
246
+ document.addEventListener('DOMContentLoaded', function() {
247
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
248
+ try {
249
+ const chartOptions = ${optionsObjectString};
250
+ Highcharts.chart('${chartId}', chartOptions);
251
+ } catch (e) {
252
+ console.error("Error rendering chart ${chartId}:", e);
253
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering test trends chart.</div>';
254
+ }
255
+ } else {
256
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
257
+ }
258
+ });
259
+ </script>
260
+ `;
261
+ }
262
+
263
+ function generateDurationTrendChart(trendData) {
264
+ if (!trendData || !trendData.overall || trendData.overall.length === 0) {
265
+ return '<div class="no-data">No overall trend data available for durations.</div>';
266
+ }
267
+ const chartId = `durationTrendChart-${Date.now()}-${Math.random()
268
+ .toString(36)
269
+ .substring(2, 7)}`;
270
+ const runs = trendData.overall;
271
+
272
+ // Assuming var(--accent-color-alt) is Orange #FF9800
273
+ const accentColorAltRGB = "255, 152, 0";
274
+
275
+ const seriesString = `[{
276
+ name: 'Duration',
277
+ data: ${JSON.stringify(runs.map((run) => run.duration))},
278
+ color: 'var(--accent-color-alt)',
279
+ type: 'area',
280
+ marker: {
281
+ symbol: 'circle', enabled: true, radius: 4,
282
+ states: { hover: { radius: 6, lineWidthPlus: 0 } }
283
+ },
284
+ fillColor: {
285
+ linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
286
+ stops: [
287
+ [0, 'rgba(${accentColorAltRGB}, 0.4)'],
288
+ [1, 'rgba(${accentColorAltRGB}, 0.05)']
289
+ ]
290
+ },
291
+ lineWidth: 2.5
292
+ }]`;
293
+
294
+ const runsForTooltip = runs.map((r) => ({
295
+ runId: r.runId,
296
+ timestamp: r.timestamp,
297
+ duration: r.duration,
298
+ totalTests: r.totalTests,
299
+ }));
300
+
301
+ const optionsObjectString = `
302
+ {
303
+ chart: { type: 'area', height: 350, backgroundColor: 'transparent' },
304
+ title: { text: null },
305
+ xAxis: {
306
+ categories: ${JSON.stringify(runs.map((run, i) => `Run ${i + 1}`))},
307
+ crosshair: true,
308
+ labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' } }
309
+ },
310
+ yAxis: {
311
+ title: { text: 'Duration', style: { color: 'var(--text-color)' } },
312
+ labels: {
313
+ formatter: function() { return formatDuration(this.value); },
314
+ style: { color: 'var(--text-color-secondary)', fontSize: '12px' }
315
+ },
316
+ min: 0
317
+ },
318
+ legend: {
319
+ layout: 'horizontal', align: 'center', verticalAlign: 'bottom',
320
+ itemStyle: { fontSize: '12px', color: 'var(--text-color)' }
321
+ },
322
+ plotOptions: {
323
+ area: {
324
+ lineWidth: 2.5,
325
+ states: { hover: { lineWidthPlus: 0 } },
326
+ threshold: null
327
+ }
328
+ },
329
+ tooltip: {
330
+ shared: true, useHTML: true,
331
+ backgroundColor: 'rgba(10,10,10,0.92)',
332
+ borderColor: 'rgba(10,10,10,0.92)',
333
+ style: { color: '#f5f5f5' },
334
+ formatter: function () {
335
+ const runsData = ${JSON.stringify(runsForTooltip)};
336
+ const pointIndex = this.points[0].point.x;
337
+ const run = runsData[pointIndex];
338
+ let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' +
339
+ 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br>';
340
+ this.points.forEach(point => {
341
+ tooltip += '<span style="color:' + point.series.color + '">●</span> ' +
342
+ point.series.name + ': <b>' + formatDuration(point.y) + '</b><br>';
343
+ });
344
+ tooltip += '<br>Tests: ' + run.totalTests;
345
+ return tooltip;
346
+ }
347
+ },
348
+ series: ${seriesString},
349
+ credits: { enabled: false }
350
+ }
351
+ `;
352
+
353
+ return `
354
+ <div id="${chartId}" class="trend-chart-container"></div>
355
+ <script>
356
+ document.addEventListener('DOMContentLoaded', function() {
357
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
358
+ try {
359
+ const chartOptions = ${optionsObjectString};
360
+ Highcharts.chart('${chartId}', chartOptions);
361
+ } catch (e) {
362
+ console.error("Error rendering chart ${chartId}:", e);
363
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering duration trend chart.</div>';
364
+ }
365
+ } else {
366
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
367
+ }
368
+ });
369
+ </script>
370
+ `;
371
+ }
372
+
373
+ function formatDate(dateStrOrDate) {
374
+ if (!dateStrOrDate) return "N/A";
375
+ try {
376
+ const date = new Date(dateStrOrDate);
377
+ if (isNaN(date.getTime())) return "Invalid Date";
378
+ return (
379
+ date.toLocaleDateString(undefined, {
380
+ year: "2-digit",
381
+ month: "2-digit",
382
+ day: "2-digit",
383
+ }) +
384
+ " " +
385
+ date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
386
+ );
387
+ } catch (e) {
388
+ return "Invalid Date Format";
389
+ }
390
+ }
391
+
392
+ function generateTestHistoryChart(history) {
393
+ if (!history || history.length === 0)
394
+ return '<div class="no-data-chart">No data for chart</div>';
395
+
396
+ const validHistory = history.filter(
397
+ (h) => h && typeof h.duration === "number" && h.duration >= 0
398
+ );
399
+ if (validHistory.length === 0)
400
+ return '<div class="no-data-chart">No valid data for chart</div>';
401
+
402
+ const chartId = `testHistoryChart-${Date.now()}-${Math.random()
403
+ .toString(36)
404
+ .substring(2, 7)}`;
405
+
406
+ const seriesDataPoints = validHistory.map((run) => {
407
+ let color;
408
+ switch (String(run.status).toLowerCase()) {
409
+ case "passed":
410
+ color = "var(--success-color)";
411
+ break;
412
+ case "failed":
413
+ color = "var(--danger-color)";
414
+ break;
415
+ case "skipped":
416
+ color = "var(--warning-color)";
417
+ break;
418
+ default:
419
+ color = "var(--dark-gray-color)";
420
+ }
421
+ return {
422
+ y: run.duration,
423
+ marker: {
424
+ fillColor: color,
425
+ symbol: "circle",
426
+ radius: 3.5,
427
+ states: { hover: { radius: 5 } },
428
+ },
429
+ status: run.status,
430
+ runId: run.runId,
431
+ };
432
+ });
433
+
434
+ // Assuming var(--accent-color) is Deep Purple #673ab7 -> RGB 103, 58, 183
435
+ const accentColorRGB = "103, 58, 183";
436
+
437
+ const optionsObjectString = `
438
+ {
439
+ chart: { type: 'area', height: 100, width: 320, backgroundColor: 'transparent', spacing: [10,10,15,35] },
440
+ title: { text: null },
441
+ xAxis: {
442
+ categories: ${JSON.stringify(
443
+ validHistory.map((_, i) => `R${i + 1}`)
444
+ )},
445
+ labels: { style: { fontSize: '10px', color: 'var(--text-color-secondary)' } }
446
+ },
447
+ yAxis: {
448
+ title: { text: null },
449
+ labels: {
450
+ formatter: function() { return formatDuration(this.value); },
451
+ style: { fontSize: '10px', color: 'var(--text-color-secondary)' },
452
+ align: 'left', x: -35, y: 3
453
+ },
454
+ min: 0,
455
+ gridLineWidth: 0,
456
+ tickAmount: 4
457
+ },
458
+ legend: { enabled: false },
459
+ plotOptions: {
460
+ area: {
461
+ lineWidth: 2,
462
+ lineColor: 'var(--accent-color)',
463
+ fillColor: {
464
+ linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
465
+ stops: [
466
+ [0, 'rgba(${accentColorRGB}, 0.4)'],
467
+ [1, 'rgba(${accentColorRGB}, 0)']
468
+ ]
469
+ },
470
+ marker: { enabled: true },
471
+ threshold: null
472
+ }
473
+ },
474
+ tooltip: {
475
+ useHTML: true,
476
+ backgroundColor: 'rgba(10,10,10,0.92)',
477
+ borderColor: 'rgba(10,10,10,0.92)',
478
+ style: { color: '#f5f5f5', padding: '8px' },
479
+ formatter: function() {
480
+ const pointData = this.point;
481
+ let statusBadgeHtml = '<span style="padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; background-color: ';
482
+ switch(String(pointData.status).toLowerCase()) {
483
+ case 'passed': statusBadgeHtml += 'var(--success-color)'; break;
484
+ case 'failed': statusBadgeHtml += 'var(--danger-color)'; break;
485
+ case 'skipped': statusBadgeHtml += 'var(--warning-color)'; break;
486
+ default: statusBadgeHtml += 'var(--dark-gray-color)';
487
+ }
488
+ statusBadgeHtml += ';">' + String(pointData.status).toUpperCase() + '</span>';
489
+
490
+ return '<strong>Run ' + (pointData.runId || (this.point.index + 1)) + '</strong><br>' +
491
+ 'Status: ' + statusBadgeHtml + '<br>' +
492
+ 'Duration: ' + formatDuration(pointData.y);
493
+ }
494
+ },
495
+ series: [{
496
+ data: ${JSON.stringify(seriesDataPoints)},
497
+ showInLegend: false
498
+ }],
499
+ credits: { enabled: false }
500
+ }
501
+ `;
502
+ return `
503
+ <div id="${chartId}" style="width: 320px; height: 100px;"></div>
504
+ <script>
505
+ document.addEventListener('DOMContentLoaded', function() {
506
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
507
+ try {
508
+ const chartOptions = ${optionsObjectString};
509
+ Highcharts.chart('${chartId}', chartOptions);
510
+ } catch (e) {
511
+ console.error("Error rendering chart ${chartId}:", e);
512
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data-chart">Error rendering history chart.</div>';
513
+ }
514
+ } else {
515
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data-chart">Charting library not available.</div>';
516
+ }
517
+ });
518
+ </script>
519
+ `;
520
+ }
521
+
522
+ function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
523
+ const total = data.reduce((sum, d) => sum + d.value, 0);
524
+ if (total === 0) {
525
+ return '<div class="pie-chart-wrapper"><h3>Test Distribution</h3><div class="no-data">No data for Test Distribution chart.</div></div>';
526
+ }
527
+ const passedEntry = data.find((d) => d.label === "Passed");
528
+ const passedPercentage = Math.round(
529
+ ((passedEntry ? passedEntry.value : 0) / total) * 100
530
+ );
531
+
532
+ const chartId = `pieChart-${Date.now()}-${Math.random()
533
+ .toString(36)
534
+ .substring(2, 7)}`;
535
+
536
+ const seriesData = [
537
+ {
538
+ name: "Tests", // Changed from 'Test Distribution' for tooltip clarity
539
+ data: data
540
+ .filter((d) => d.value > 0)
541
+ .map((d) => {
542
+ let color;
543
+ switch (d.label) {
544
+ case "Passed":
545
+ color = "var(--success-color)";
546
+ break;
547
+ case "Failed":
548
+ color = "var(--danger-color)";
549
+ break;
550
+ case "Skipped":
551
+ color = "var(--warning-color)";
552
+ break;
553
+ default:
554
+ color = "#CCCCCC"; // A neutral default color
555
+ }
556
+ return { name: d.label, y: d.value, color: color };
557
+ }),
558
+ size: "100%",
559
+ innerSize: "55%",
560
+ dataLabels: { enabled: false },
561
+ showInLegend: true,
562
+ },
563
+ ];
564
+
565
+ // Approximate font size for center text, can be adjusted or made dynamic with more client-side JS
566
+ const centerTitleFontSize =
567
+ Math.max(12, Math.min(chartWidth, chartHeight) / 12) + "px";
568
+ const centerSubtitleFontSize =
569
+ Math.max(10, Math.min(chartWidth, chartHeight) / 18) + "px";
570
+
571
+ const optionsObjectString = `
572
+ {
573
+ chart: {
574
+ type: 'pie',
575
+ width: ${chartWidth},
576
+ height: ${
577
+ chartHeight - 40
578
+ }, // Adjusted height to make space for legend if chartHeight is for the whole wrapper
579
+ backgroundColor: 'transparent',
580
+ plotShadow: false,
581
+ spacingBottom: 40 // Ensure space for legend
582
+ },
583
+ title: {
584
+ text: '${passedPercentage}%',
585
+ align: 'center',
586
+ verticalAlign: 'middle',
587
+ y: 5,
588
+ style: { fontSize: '${centerTitleFontSize}', fontWeight: 'bold', color: 'var(--primary-color)' }
589
+ },
590
+ subtitle: {
591
+ text: 'Passed',
592
+ align: 'center',
593
+ verticalAlign: 'middle',
594
+ y: 25,
595
+ style: { fontSize: '${centerSubtitleFontSize}', color: 'var(--text-color-secondary)' }
596
+ },
597
+ tooltip: {
598
+ pointFormat: '{series.name}: <b>{point.percentage:.1f}%</b> ({point.y})',
599
+ backgroundColor: 'rgba(10,10,10,0.92)',
600
+ borderColor: 'rgba(10,10,10,0.92)',
601
+ style: { color: '#f5f5f5' }
602
+ },
603
+ legend: {
604
+ layout: 'horizontal',
605
+ align: 'center',
606
+ verticalAlign: 'bottom',
607
+ itemStyle: { color: 'var(--text-color)', fontWeight: 'normal', fontSize: '12px' }
608
+ },
609
+ plotOptions: {
610
+ pie: {
611
+ allowPointSelect: true,
612
+ cursor: 'pointer',
613
+ borderWidth: 3,
614
+ borderColor: 'var(--card-background-color)', // Match D3 style
615
+ states: {
616
+ hover: {
617
+ // Using default Highcharts halo which is generally good
618
+ }
619
+ }
620
+ }
621
+ },
622
+ series: ${JSON.stringify(seriesData)},
623
+ credits: { enabled: false }
624
+ }
625
+ `;
626
+
627
+ return `
628
+ <div class="pie-chart-wrapper" style="align-items: center">
629
+ <div style="display: flex; align-items: start; width: 100%;"><h3>Test Distribution</h3></div>
630
+ <div id="${chartId}" style="width: ${chartWidth}px; height: ${
631
+ chartHeight - 40
632
+ }px;"></div>
633
+ <script>
634
+ document.addEventListener('DOMContentLoaded', function() {
635
+ if (typeof Highcharts !== 'undefined') {
636
+ try {
637
+ const chartOptions = ${optionsObjectString};
638
+ Highcharts.chart('${chartId}', chartOptions);
639
+ } catch (e) {
640
+ console.error("Error rendering chart ${chartId}:", e);
641
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering pie chart.</div>';
642
+ }
643
+ } else {
644
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
645
+ }
646
+ });
647
+ </script>
648
+ </div>
649
+ `;
650
+ }
651
+
652
+ function generateTestHistoryContent(trendData) {
653
+ if (
654
+ !trendData ||
655
+ !trendData.testRuns ||
656
+ Object.keys(trendData.testRuns).length === 0
657
+ ) {
658
+ return '<div class="no-data">No historical test data available.</div>';
659
+ }
660
+
661
+ const allTestNamesAndPaths = new Map();
662
+ Object.values(trendData.testRuns).forEach((run) => {
663
+ if (Array.isArray(run)) {
664
+ run.forEach((test) => {
665
+ if (test && test.testName && !allTestNamesAndPaths.has(test.testName)) {
666
+ const parts = test.testName.split(" > ");
667
+ const title = parts[parts.length - 1];
668
+ allTestNamesAndPaths.set(test.testName, title);
669
+ }
670
+ });
671
+ }
672
+ });
673
+
674
+ if (allTestNamesAndPaths.size === 0) {
675
+ return '<div class="no-data">No historical test data found after processing.</div>';
676
+ }
677
+
678
+ const testHistory = Array.from(allTestNamesAndPaths.entries())
679
+ .map(([fullTestName, testTitle]) => {
680
+ const history = [];
681
+ (trendData.overall || []).forEach((overallRun, index) => {
682
+ const runKey = overallRun.runId
683
+ ? `test run ${overallRun.runId}`
684
+ : `test run ${index + 1}`;
685
+ const testRunForThisOverallRun = trendData.testRuns[runKey]?.find(
686
+ (t) => t && t.testName === fullTestName
687
+ );
688
+ if (testRunForThisOverallRun) {
689
+ history.push({
690
+ runId: overallRun.runId || index + 1,
691
+ status: testRunForThisOverallRun.status || "unknown",
692
+ duration: testRunForThisOverallRun.duration || 0,
693
+ timestamp:
694
+ testRunForThisOverallRun.timestamp ||
695
+ overallRun.timestamp ||
696
+ new Date(),
697
+ });
698
+ }
699
+ });
700
+ return { fullTestName, testTitle, history };
701
+ })
702
+ .filter((item) => item.history.length > 0);
703
+
704
+ return `
705
+ <div class="test-history-container">
706
+ <div class="filters" style="border-color: black; border-style: groove;">
707
+ <input type="text" id="history-filter-name" placeholder="Search by test title..." style="border-color: black; border-style: outset;">
708
+ <select id="history-filter-status">
709
+ <option value="">All Statuses</option>
710
+ <option value="passed">Passed</option>
711
+ <option value="failed">Failed</option>
712
+ <option value="skipped">Skipped</option>
713
+ </select>
714
+ <button id="clear-history-filters" class="clear-filters-btn">Clear Filters</button>
715
+ </div>
716
+
717
+ <div class="test-history-grid">
718
+ ${testHistory
719
+ .map((test) => {
720
+ const latestRun =
721
+ test.history.length > 0
722
+ ? test.history[test.history.length - 1]
723
+ : { status: "unknown" };
724
+ return `
725
+ <div class="test-history-card" data-test-name="${sanitizeHTML(
726
+ test.testTitle.toLowerCase()
727
+ )}" data-latest-status="${latestRun.status}">
728
+ <div class="test-history-header">
729
+ <p title="${sanitizeHTML(test.testTitle)}">${capitalize(
730
+ sanitizeHTML(test.testTitle)
731
+ )}</p>
732
+ <span class="status-badge ${getStatusClass(latestRun.status)}">
733
+ ${String(latestRun.status).toUpperCase()}
734
+ </span>
735
+ </div>
736
+ <div class="test-history-trend">
737
+ ${generateTestHistoryChart(test.history)}
738
+ </div>
739
+ <details class="test-history-details-collapsible">
740
+ <summary>Show Run Details (${test.history.length})</summary>
741
+ <div class="test-history-details">
742
+ <table>
743
+ <thead><tr><th>Run</th><th>Status</th><th>Duration</th><th>Date</th></tr></thead>
744
+ <tbody>
745
+ ${test.history
746
+ .slice()
747
+ .reverse()
748
+ .map(
749
+ (run) => `
750
+ <tr>
751
+ <td>${run.runId}</td>
752
+ <td><span class="status-badge-small ${getStatusClass(
753
+ run.status
754
+ )}">${String(run.status).toUpperCase()}</span></td>
755
+ <td>${formatDuration(run.duration)}</td>
756
+ <td>${formatDate(run.timestamp)}</td>
757
+ </tr>`
758
+ )
759
+ .join("")}
760
+ </tbody>
761
+ </table>
762
+ </div>
763
+ </details>
764
+ </div>`;
765
+ })
766
+ .join("")}
767
+ </div>
768
+ </div>
769
+ `;
770
+ }
771
+
772
+ function getStatusClass(status) {
773
+ switch (String(status).toLowerCase()) {
774
+ case "passed":
775
+ return "status-passed";
776
+ case "failed":
777
+ return "status-failed";
778
+ case "skipped":
779
+ return "status-skipped";
780
+ default:
781
+ return "status-unknown";
782
+ }
783
+ }
784
+
785
+ function getStatusIcon(status) {
786
+ switch (String(status).toLowerCase()) {
787
+ case "passed":
788
+ return "✅";
789
+ case "failed":
790
+ return "❌";
791
+ case "skipped":
792
+ return "⏭️";
793
+ default:
794
+ return "❓";
795
+ }
796
+ }
797
+
798
+ function getSuitesData(results) {
799
+ const suitesMap = new Map();
800
+ if (!results || results.length === 0) return [];
801
+
802
+ results.forEach((test) => {
803
+ const browser = test.browser || "unknown";
804
+ const suiteParts = test.name.split(" > ");
805
+ let suiteNameCandidate = "Default Suite";
806
+ if (suiteParts.length > 2) {
807
+ suiteNameCandidate = suiteParts[1];
808
+ } else if (suiteParts.length > 1) {
809
+ suiteNameCandidate = suiteParts[0]
810
+ .split(path.sep)
811
+ .pop()
812
+ .replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
813
+ } else {
814
+ suiteNameCandidate = test.name
815
+ .split(path.sep)
816
+ .pop()
817
+ .replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
818
+ }
819
+ const suiteName = suiteNameCandidate;
820
+ const key = `${suiteName}|${browser}`;
821
+
822
+ if (!suitesMap.has(key)) {
823
+ suitesMap.set(key, {
824
+ id: test.id || key,
825
+ name: suiteName,
826
+ browser: browser,
827
+ passed: 0,
828
+ failed: 0,
829
+ skipped: 0,
830
+ count: 0,
831
+ statusOverall: "passed",
832
+ });
833
+ }
834
+ const suite = suitesMap.get(key);
835
+ suite.count++;
836
+ const currentStatus = String(test.status).toLowerCase();
837
+ if (currentStatus && suite[currentStatus] !== undefined) {
838
+ suite[currentStatus]++;
839
+ }
840
+
841
+ if (currentStatus === "failed") {
842
+ suite.statusOverall = "failed";
843
+ } else if (
844
+ currentStatus === "skipped" &&
845
+ suite.statusOverall !== "failed"
846
+ ) {
847
+ suite.statusOverall = "skipped";
848
+ }
849
+ });
850
+ return Array.from(suitesMap.values());
851
+ }
852
+
853
+ function generateSuitesWidget(suitesData) {
854
+ if (!suitesData || suitesData.length === 0) {
855
+ return `<div class="suites-widget"><div class="suites-header"><h2>Test Suites</h2></div><div class="no-data">No suite data available.</div></div>`;
856
+ }
857
+ return `
858
+ <div class="suites-widget">
859
+ <div class="suites-header">
860
+ <h2>Test Suites</h2>
861
+ <span class="summary-badge">
862
+ ${suitesData.length} suites • ${suitesData.reduce(
863
+ (sum, suite) => sum + suite.count,
864
+ 0
865
+ )} tests
866
+ </span>
867
+ </div>
868
+ <div class="suites-grid">
869
+ ${suitesData
870
+ .map(
871
+ (suite) => `
872
+ <div class="suite-card status-${suite.statusOverall}">
873
+ <div class="suite-card-header">
874
+ <h3 class="suite-name" title="${sanitizeHTML(
875
+ suite.name
876
+ )} (${sanitizeHTML(suite.browser)})">${sanitizeHTML(suite.name)}</h3>
877
+ <span class="browser-tag">${sanitizeHTML(suite.browser)}</span>
878
+ </div>
879
+ <div class="suite-card-body">
880
+ <span class="test-count">${suite.count} test${
881
+ suite.count !== 1 ? "s" : ""
882
+ }</span>
883
+ <div class="suite-stats">
884
+ ${
885
+ suite.passed > 0
886
+ ? `<span class="stat-passed" title="Passed"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check-circle-fill" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg> ${suite.passed}</span>`
887
+ : ""
888
+ }
889
+ ${
890
+ suite.failed > 0
891
+ ? `<span class="stat-failed" title="Failed"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg> ${suite.failed}</span>`
892
+ : ""
893
+ }
894
+ ${
895
+ suite.skipped > 0
896
+ ? `<span class="stat-skipped" title="Skipped"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> ${suite.skipped}</span>`
897
+ : ""
898
+ }
899
+ </div>
900
+ </div>
901
+ </div>`
902
+ )
903
+ .join("")}
904
+ </div>
905
+ </div>`;
906
+ }
907
+
908
+ function generateHTML(reportData, trendData = null) {
909
+ const { run, results } = reportData;
910
+ const suitesData = getSuitesData(reportData.results || []);
911
+ const runSummary = run || {
912
+ totalTests: 0,
913
+ passed: 0,
914
+ failed: 0,
915
+ skipped: 0,
916
+ duration: 0,
917
+ timestamp: new Date().toISOString(),
918
+ };
919
+
920
+ const totalTestsOr1 = runSummary.totalTests || 1; // Avoid division by zero
921
+ const passPercentage = Math.round((runSummary.passed / totalTestsOr1) * 100);
922
+ const failPercentage = Math.round((runSummary.failed / totalTestsOr1) * 100);
923
+ const skipPercentage = Math.round(
924
+ ((runSummary.skipped || 0) / totalTestsOr1) * 100
925
+ );
926
+ const avgTestDuration =
927
+ runSummary.totalTests > 0
928
+ ? formatDuration(runSummary.duration / runSummary.totalTests)
929
+ : "0.0s";
930
+
931
+ function generateTestCasesHTML() {
932
+ if (!results || results.length === 0) {
933
+ return '<div class="no-tests">No test results found in this run.</div>';
934
+ }
935
+
936
+ return results
937
+ .map((test, index) => {
938
+ const browser = test.browser || "unknown";
939
+ const testFileParts = test.name.split(" > ");
940
+ const testTitle =
941
+ testFileParts[testFileParts.length - 1] || "Unnamed Test";
942
+
943
+ const generateStepsHTML = (steps, depth = 0) => {
944
+ if (!steps || steps.length === 0)
945
+ return "<div class='no-steps'>No steps recorded for this test.</div>";
946
+ return steps
947
+ .map((step) => {
948
+ const hasNestedSteps = step.steps && step.steps.length > 0;
949
+ const isHook = step.hookType;
950
+ const stepClass = isHook
951
+ ? `step-hook step-hook-${step.hookType}`
952
+ : "";
953
+ const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
954
+
955
+ return `
956
+ <div class="step-item" style="--depth: ${depth};">
957
+ <div class="step-header ${stepClass}" role="button" aria-expanded="false">
958
+ <span class="step-icon">${getStatusIcon(step.status)}</span>
959
+ <span class="step-title">${sanitizeHTML(
960
+ step.title
961
+ )}${hookIndicator}</span>
962
+ <span class="step-duration">${formatDuration(
963
+ step.duration
964
+ )}</span>
965
+ </div>
966
+ <div class="step-details" style="display: none;">
967
+ ${
968
+ step.codeLocation
969
+ ? `<div class="step-info"><strong>Location:</strong> ${sanitizeHTML(
970
+ step.codeLocation
971
+ )}</div>`
972
+ : ""
973
+ }
974
+ ${
975
+ step.errorMessage
976
+ ? `
977
+ <div class="step-error">
978
+ ${
979
+ step.stackTrace
980
+ ? `<pre class="stack-trace">${sanitizeHTML(
981
+ step.stackTrace
982
+ )}</pre>`
983
+ : ""
984
+ }
985
+ </div>`
986
+ : ""
987
+ }
988
+ ${
989
+ hasNestedSteps
990
+ ? `<div class="nested-steps">${generateStepsHTML(
991
+ step.steps,
992
+ depth + 1
993
+ )}</div>`
994
+ : ""
995
+ }
996
+ </div>
997
+ </div>`;
998
+ })
999
+ .join("");
1000
+ };
1001
+
1002
+ return `
1003
+ <div class="test-case" data-status="${
1004
+ test.status
1005
+ }" data-browser="${sanitizeHTML(browser)}" data-tags="${(test.tags || [])
1006
+ .join(",")
1007
+ .toLowerCase()}">
1008
+ <div class="test-case-header" role="button" aria-expanded="false">
1009
+ <div class="test-case-summary">
1010
+ <span class="status-badge ${getStatusClass(test.status)}">${String(
1011
+ test.status
1012
+ ).toUpperCase()}</span>
1013
+ <span class="test-case-title" title="${sanitizeHTML(
1014
+ test.name
1015
+ )}">${sanitizeHTML(testTitle)}</span>
1016
+ <span class="test-case-browser">(${sanitizeHTML(browser)})</span>
1017
+ </div>
1018
+ <div class="test-case-meta">
1019
+ ${
1020
+ test.tags && test.tags.length > 0
1021
+ ? test.tags
1022
+ .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1023
+ .join(" ")
1024
+ : ""
1025
+ }
1026
+ <span class="test-duration">${formatDuration(test.duration)}</span>
1027
+ </div>
1028
+ </div>
1029
+ <div class="test-case-content" style="display: none;">
1030
+ <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
1031
+ ${
1032
+ test.error
1033
+ ? `<div class="test-error-summary">
1034
+ ${formatPlaywrightError(test.error)}
1035
+ </div>`
1036
+ : ""
1037
+ }
1038
+
1039
+ <h4>Steps</h4>
1040
+ <div class="steps-list">${generateStepsHTML(test.steps)}</div>
1041
+
1042
+ ${
1043
+ test.stdout && test.stdout.length > 0
1044
+ ? `
1045
+ <div class="console-output-section">
1046
+ <h4>Console Output (stdout)</h4>
1047
+ <pre class="console-log stdout-log" style="background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2;">${test.stdout
1048
+ .map((line) => sanitizeHTML(line))
1049
+ .join("\n")}</pre>
1050
+ </div>`
1051
+ : ""
1052
+ }
1053
+ ${
1054
+ test.stderr && test.stderr.length > 0
1055
+ ? `
1056
+ <div class="console-output-section">
1057
+ <h4>Console Output (stderr)</h4>
1058
+ <pre class="console-log stderr-log" style="background-color: #2d2d2d; color: indianred; padding: 1.25em; border-radius: 0.85em; line-height: 1.2;">${test.stderr
1059
+ .map((line) => sanitizeHTML(line))
1060
+ .join("\n")}</pre>
1061
+ </div>`
1062
+ : ""
1063
+ }
1064
+
1065
+ ${(() => {
1066
+ if (
1067
+ !test.screenshots ||
1068
+ test.screenshots.length === 0
1069
+ )
1070
+ return "";
1071
+
1072
+ // Define base output directory to resolve relative screenshot paths
1073
+ // This assumes screenshot paths in your JSON are relative to DEFAULT_OUTPUT_DIR
1074
+ const baseOutputDir = path.resolve(
1075
+ process.cwd(),
1076
+ DEFAULT_OUTPUT_DIR
1077
+ );
1078
+
1079
+ // Helper to escape HTML special characters (safer than the global sanitizeHTML)
1080
+ const escapeHTML = (str) => {
1081
+ if (str === null || str === undefined) return "";
1082
+ return String(str).replace(
1083
+ /[&<>"']/g,
1084
+ (match) => {
1085
+ const replacements = {
1086
+ "&": "&",
1087
+ "<": "<",
1088
+ ">": ">",
1089
+ '"': '"',
1090
+ "'": "'",
1091
+ };
1092
+ return replacements[match] || match;
1093
+ }
1094
+ );
1095
+ };
1096
+
1097
+ const renderScreenshot = (
1098
+ screenshotPathOrData,
1099
+ index
1100
+ ) => {
1101
+ let base64ImageData = "";
1102
+ const uniqueSuffix = `${Date.now()}-${index}-${Math.random()
1103
+ .toString(36)
1104
+ .substring(2, 7)}`;
1105
+
1106
+ try {
1107
+ if (
1108
+ typeof screenshotPathOrData === "string" &&
1109
+ !screenshotPathOrData.startsWith("data:image")
1110
+ ) {
1111
+ // It's likely a file path, try to read and convert
1112
+ const imagePath = path.resolve(
1113
+ baseOutputDir,
1114
+ screenshotPathOrData
1115
+ );
1116
+
1117
+ if (fsExistsSync(imagePath)) {
1118
+ // Use imported fsExistsSync
1119
+ const imageBuffer = readFileSync(imagePath); // Use imported readFileSync
1120
+ base64ImageData =
1121
+ imageBuffer.toString("base64");
1122
+ } else {
1123
+ console.warn(
1124
+ chalk.yellow(
1125
+ `[Reporter] Screenshot file not found: ${imagePath}`
1126
+ )
1127
+ );
1128
+ return `<div class="attachment-item error" style="padding:10px; color:red;">Screenshot not found: ${escapeHTML(
1129
+ screenshotPathOrData
1130
+ )}</div>`;
1131
+ }
1132
+ } else if (
1133
+ typeof screenshotPathOrData === "string" &&
1134
+ screenshotPathOrData.startsWith(
1135
+ "data:image/png;base64,"
1136
+ )
1137
+ ) {
1138
+ // It's already a data URI, extract base64 part
1139
+ base64ImageData =
1140
+ screenshotPathOrData.substring(
1141
+ "data:image/png;base64,".length
1142
+ );
1143
+ } else if (
1144
+ typeof screenshotPathOrData === "string"
1145
+ ) {
1146
+ // Assume it's raw Base64 data if it's a string but not a known path or full data URI
1147
+ base64ImageData = screenshotPathOrData;
1148
+ } else {
1149
+ console.warn(
1150
+ chalk.yellow(
1151
+ `[Reporter] Invalid screenshot data type for item at index ${index}.`
1152
+ )
1153
+ );
1154
+ return `<div class="attachment-item error" style="padding:10px; color:red;">Invalid screenshot data</div>`;
1155
+ }
1156
+
1157
+ if (!base64ImageData) {
1158
+ // This case should ideally be caught above, but as a fallback:
1159
+ console.warn(
1160
+ chalk.yellow(
1161
+ `[Reporter] Could not obtain base64 data for screenshot: ${escapeHTML(
1162
+ String(screenshotPathOrData)
1163
+ )}`
1164
+ )
1165
+ );
1166
+ return `<div class="attachment-item error" style="padding:10px; color:red;">Error loading screenshot: ${escapeHTML(
1167
+ String(screenshotPathOrData)
1168
+ )}</div>`;
1169
+ }
1170
+
1171
+ return `
1172
+ <div class="attachment-item">
1173
+ <img src="data:image/png;base64,${base64ImageData}"
1174
+ alt="Screenshot ${index + 1}"
1175
+ loading="lazy"
1176
+ onerror="this.alt='Error displaying embedded image'; this.style.display='none'; this.parentElement.innerHTML='<p style=\\'color:red;padding:10px;\\'>Error displaying screenshot ${
1177
+ index + 1
1178
+ }.</p>';">
1179
+ <div class="attachment-info">
1180
+ <div class="trace-actions">
1181
+ <a href="data:image/png;base64,${base64ImageData}"
1182
+ target="_blank"
1183
+ class="view-full">
1184
+ View Full Image
1185
+ </a>
1186
+ <a href="data:image/png;base64,${base64ImageData}"
1187
+ target="_blank"
1188
+ download="screenshot-${uniqueSuffix}.png">
1189
+ Download
1190
+ </a>
1191
+ </div>
1192
+ </div>
1193
+ </div>`;
1194
+ } catch (e) {
1195
+ console.error(
1196
+ chalk.red(
1197
+ `[Reporter] Error processing screenshot ${escapeHTML(
1198
+ String(screenshotPathOrData)
1199
+ )}: ${e.message}`
1200
+ )
1201
+ );
1202
+ return `<div class="attachment-item error" style="padding:10px; color:red;">Failed to load screenshot: ${escapeHTML(
1203
+ String(screenshotPathOrData)
1204
+ )}</div>`;
1205
+ }
1206
+ }; // end of renderScreenshot
1207
+
1208
+ return `
1209
+ <div class="attachments-section">
1210
+ <h4>Screenshots (${test.screenshots.length})</h4>
1211
+ <div class="attachments-grid">
1212
+ ${test.screenshots.map(renderScreenshot).join("")}
1213
+ </div>
1214
+ </div>
1215
+ `;
1216
+ })()}
1217
+
1218
+ ${
1219
+ test.videoPath
1220
+ ? `
1221
+ <div class="attachments-section">
1222
+ <h4>Videos</h4>
1223
+ <div class="attachments-grid">
1224
+ ${(() => {
1225
+ // Handle both string and array cases
1226
+ const videos = Array.isArray(test.videoPath)
1227
+ ? test.videoPath
1228
+ : [test.videoPath];
1229
+ const mimeTypes = {
1230
+ mp4: "video/mp4",
1231
+ webm: "video/webm",
1232
+ ogg: "video/ogg",
1233
+ mov: "video/quicktime",
1234
+ avi: "video/x-msvideo",
1235
+ };
1236
+
1237
+ return videos
1238
+ .map((video, index) => {
1239
+ const videoUrl =
1240
+ typeof video === "object" ? video.url || "" : video;
1241
+ const videoName =
1242
+ typeof video === "object"
1243
+ ? video.name || `Video ${index + 1}`
1244
+ : `Video ${index + 1}`;
1245
+ const fileExtension = videoUrl
1246
+ .split(".")
1247
+ .pop()
1248
+ .toLowerCase();
1249
+ const mimeType = mimeTypes[fileExtension] || "video/mp4";
1250
+
1251
+ return `
1252
+ <div class="attachment-item">
1253
+ <video controls width="100%" height="auto" title="${videoName}">
1254
+ <source src="${videoUrl}" type="${mimeType}">
1255
+ Your browser does not support the video tag.
1256
+ </video>
1257
+ <div class="attachment-info">
1258
+ <div class="trace-actions">
1259
+ <a href="${videoUrl}" target="_blank" download="${videoName}.${fileExtension}">
1260
+ Download
1261
+ </a>
1262
+ </div>
1263
+ </div>
1264
+ </div>
1265
+ `;
1266
+ })
1267
+ .join("");
1268
+ })()}
1269
+ </div>
1270
+ </div>
1271
+ `
1272
+ : ""
1273
+ }
1274
+
1275
+ ${
1276
+ test.tracePath
1277
+ ? `
1278
+ <div class="attachments-section">
1279
+ <h4>Trace Files</h4>
1280
+ <div class="attachments-grid">
1281
+ ${(() => {
1282
+ // Handle both string and array cases
1283
+ const traces = Array.isArray(test.tracePath)
1284
+ ? test.tracePath
1285
+ : [test.tracePath];
1286
+
1287
+ return traces
1288
+ .map((trace, index) => {
1289
+ const traceUrl =
1290
+ typeof trace === "object" ? trace.url || "" : trace;
1291
+ const traceName =
1292
+ typeof trace === "object"
1293
+ ? trace.name || `Trace ${index + 1}`
1294
+ : `Trace ${index + 1}`;
1295
+ const traceFileName = traceUrl.split("/").pop();
1296
+
1297
+ return `
1298
+ <div class="attachment-item">
1299
+ <div class="trace-preview">
1300
+ <span class="trace-icon">📄</span>
1301
+ <span class="trace-name">${traceName}</span>
1302
+ </div>
1303
+ <div class="attachment-info">
1304
+ <div class="trace-actions">
1305
+ <a href="${traceUrl}" target="_blank" download="${traceFileName}" class="download-trace">
1306
+ Download
1307
+ </a>
1308
+ </div>
1309
+ </div>
1310
+ </div>
1311
+ `;
1312
+ })
1313
+ .join("");
1314
+ })()}
1315
+ </div>
1316
+ </div>
1317
+ `
1318
+ : ""
1319
+ }
1320
+
1321
+ ${
1322
+ test.codeSnippet
1323
+ ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
1324
+ test.codeSnippet
1325
+ )}</code></pre></div>`
1326
+ : ""
1327
+ }
1328
+ </div>
1329
+ </div>`;
1330
+ })
1331
+ .join("");
1332
+ }
1333
+
1334
+ return `
1335
+ <!DOCTYPE html>
1336
+ <html lang="en">
1337
+ <head>
1338
+ <meta charset="UTF-8">
1339
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1340
+ <link rel="icon" type="image/png" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1341
+ <link rel="apple-touch-icon" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1342
+ <script src="https://code.highcharts.com/highcharts.js"></script>
1343
+ <title>Playwright Pulse Report</title>
1344
+ <style>
1345
+ :root {
1346
+ --primary-color: #3f51b5; /* Indigo */
1347
+ --secondary-color: #ff4081; /* Pink */
1348
+ --accent-color: #673ab7; /* Deep Purple */
1349
+ --accent-color-alt: #FF9800; /* Orange for duration charts */
1350
+ --success-color: #4CAF50; /* Green */
1351
+ --danger-color: #F44336; /* Red */
1352
+ --warning-color: #FFC107; /* Amber */
1353
+ --info-color: #2196F3; /* Blue */
1354
+ --light-gray-color: #f5f5f5;
1355
+ --medium-gray-color: #e0e0e0;
1356
+ --dark-gray-color: #757575;
1357
+ --text-color: #333;
1358
+ --text-color-secondary: #555;
1359
+ --border-color: #ddd;
1360
+ --background-color: #f8f9fa;
1361
+ --card-background-color: #fff;
1362
+ --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
1363
+ --border-radius: 8px;
1364
+ --box-shadow: 0 5px 15px rgba(0,0,0,0.08);
1365
+ --box-shadow-light: 0 3px 8px rgba(0,0,0,0.05);
1366
+ --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.07);
1367
+ }
1368
+
1369
+ /* General Highcharts styling */
1370
+ .highcharts-background { fill: transparent; }
1371
+ .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
1372
+ .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
1373
+ .highcharts-axis-title { fill: var(--text-color) !important; }
1374
+ .highcharts-tooltip > span { background-color: rgba(10,10,10,0.92) !important; border-color: rgba(10,10,10,0.92) !important; color: #f5f5f5 !important; padding: 10px !important; border-radius: 6px !important; }
1375
+
1376
+ body {
1377
+ font-family: var(--font-family);
1378
+ margin: 0;
1379
+ background-color: var(--background-color);
1380
+ color: var(--text-color);
1381
+ line-height: 1.65;
1382
+ font-size: 16px;
1383
+ }
1384
+
1385
+ .container {
1386
+ max-width: 1600px;
1387
+ padding: 30px;
1388
+ border-radius: var(--border-radius);
1389
+ box-shadow: var(--box-shadow);
1390
+ background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec);
1391
+ }
1392
+
1393
+ .header {
1394
+ display: flex;
1395
+ justify-content: space-between;
1396
+ align-items: center;
1397
+ flex-wrap: wrap;
1398
+ padding-bottom: 25px;
1399
+ border-bottom: 1px solid var(--border-color);
1400
+ margin-bottom: 25px;
1401
+ }
1402
+ .header-title { display: flex; align-items: center; gap: 15px; }
1403
+ .header h1 { margin: 0; font-size: 1.85em; font-weight: 600; color: var(--primary-color); }
1404
+ #report-logo { height: 40px; width: 40px; border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);}
1405
+ .run-info { font-size: 0.9em; text-align: right; color: var(--text-color-secondary); line-height:1.5;}
1406
+ .run-info strong { color: var(--text-color); }
1407
+
1408
+ .tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 30px; overflow-x: auto; }
1409
+ .tab-button {
1410
+ padding: 15px 25px; background: none; border: none; border-bottom: 3px solid transparent;
1411
+ cursor: pointer; font-size: 1.1em; font-weight: 600; color: black;
1412
+ transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap;
1413
+ }
1414
+ .tab-button:hover { color: var(--accent-color); }
1415
+ .tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
1416
+ .tab-content { display: none; animation: fadeIn 0.4s ease-out; }
1417
+ .tab-content.active { display: block; }
1418
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
1419
+
1420
+ .dashboard-grid {
1421
+ display: grid;
1422
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
1423
+ gap: 22px; margin-bottom: 35px;
1424
+ }
1425
+ .summary-card {
1426
+ background-color: var(--card-background-color); border: 1px solid var(--border-color);
1427
+ border-radius: var(--border-radius); padding: 22px; text-align: center;
1428
+ box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease;
1429
+ }
1430
+ .summary-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow); }
1431
+ .summary-card h3 { margin: 0 0 10px; font-size: 1.05em; font-weight: 500; color: var(--text-color-secondary); }
1432
+ .summary-card .value { font-size: 2.4em; font-weight: 600; margin-bottom: 8px; }
1433
+ .summary-card .trend-percentage { font-size: 1em; color: var(--dark-gray-color); }
1434
+ .status-passed .value, .stat-passed svg { color: var(--success-color); }
1435
+ .status-failed .value, .stat-failed svg { color: var(--danger-color); }
1436
+ .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
1437
+
1438
+ .dashboard-bottom-row {
1439
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
1440
+ gap: 28px; align-items: stretch;
1441
+ }
1442
+ .pie-chart-wrapper, .suites-widget, .trend-chart {
1443
+ background-color: var(--card-background-color); padding: 28px;
1444
+ border-radius: var(--border-radius); box-shadow: var(--box-shadow-light);
1445
+ display: flex; flex-direction: column;
1446
+ }
1447
+
1448
+ .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3 {
1449
+ text-align: center; margin-top: 0; margin-bottom: 25px;
1450
+ font-size: 1.25em; font-weight: 600; color: var(--text-color);
1451
+ }
1452
+ .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { /* For Highcharts containers */
1453
+ flex-grow: 1;
1454
+ min-height: 250px; /* Ensure charts have some min height */
1455
+ }
1456
+
1457
+ .chart-tooltip { /* This class was for D3, Highcharts has its own tooltip styling via JS/SVG */
1458
+ /* Basic styling for Highcharts HTML tooltips can be done via .highcharts-tooltip span */
1459
+ }
1460
+ .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
1461
+ .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
1462
+ .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
1463
+ .status-badge-small-tooltip.status-skipped { background-color: var(--warning-color); }
1464
+ .status-badge-small-tooltip.status-unknown { background-color: var(--dark-gray-color); }
1465
+
1466
+ .suites-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
1467
+ .summary-badge { background-color: var(--light-gray-color); color: var(--text-color-secondary); padding: 7px 14px; border-radius: 16px; font-size: 0.9em; }
1468
+ .suites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
1469
+ .suite-card {
1470
+ border: 1px solid var(--border-color); border-left-width: 5px;
1471
+ border-radius: calc(var(--border-radius) / 1.5); padding: 20px;
1472
+ background-color: var(--card-background-color); transition: box-shadow 0.2s ease, border-left-color 0.2s ease;
1473
+ }
1474
+ .suite-card:hover { box-shadow: var(--box-shadow); }
1475
+ .suite-card.status-passed { border-left-color: var(--success-color); }
1476
+ .suite-card.status-failed { border-left-color: var(--danger-color); }
1477
+ .suite-card.status-skipped { border-left-color: var(--warning-color); }
1478
+ .suite-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
1479
+ .suite-name { font-weight: 600; font-size: 1.05em; color: var(--text-color); margin-right: 10px; word-break: break-word;}
1480
+ .browser-tag { font-size: 0.8em; background-color: var(--medium-gray-color); color: var(--text-color-secondary); padding: 3px 8px; border-radius: 4px; white-space: nowrap;}
1481
+ .suite-card-body .test-count { font-size: 0.95em; color: var(--text-color-secondary); display: block; margin-bottom: 10px; }
1482
+ .suite-stats { display: flex; gap: 14px; font-size: 0.95em; align-items: center; }
1483
+ .suite-stats span { display: flex; align-items: center; gap: 6px; }
1484
+ .suite-stats svg { vertical-align: middle; font-size: 1.15em; }
1485
+
1486
+ .filters {
1487
+ display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 28px;
1488
+ padding: 20px; background-color: var(--light-gray-color); border-radius: var(--border-radius);
1489
+ box-shadow: var(--box-shadow-inset); border-color: black; border-style: groove;
1490
+ }
1491
+ .filters input, .filters select, .filters button {
1492
+ padding: 11px 15px; border: 1px solid var(--border-color);
1493
+ border-radius: 6px; font-size: 1em;
1494
+ }
1495
+ .filters input { flex-grow: 1; min-width: 240px;}
1496
+ .filters select {min-width: 180px;}
1497
+ .filters button { background-color: var(--primary-color); color: white; cursor: pointer; transition: background-color 0.2s ease, box-shadow 0.2s ease; border: none; }
1498
+ .filters button:hover { background-color: var(--accent-color); box-shadow: 0 2px 5px rgba(0,0,0,0.15);}
1499
+
1500
+ .test-case {
1501
+ margin-bottom: 15px; border: 1px solid var(--border-color);
1502
+ border-radius: var(--border-radius); background-color: var(--card-background-color);
1503
+ box-shadow: var(--box-shadow-light); overflow: hidden;
1504
+ }
1505
+ .test-case-header {
1506
+ padding: 10px 15px; background-color: #fff; cursor: pointer;
1507
+ display: flex; justify-content: space-between; align-items: center;
1508
+ border-bottom: 1px solid transparent;
1509
+ transition: background-color 0.2s ease;
1510
+ }
1511
+ .test-case-header:hover { background-color: #f4f6f8; }
1512
+ .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: #f9fafb; }
1513
+
1514
+ .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
1515
+ .test-case-title { font-weight: 600; color: var(--text-color); font-size: 1em; }
1516
+ .test-case-browser { font-size: 0.9em; color: var(--text-color-secondary); }
1517
+ .test-case-meta { display: flex; align-items: center; gap: 12px; font-size: 0.9em; color: var(--text-color-secondary); flex-shrink: 0; }
1518
+ .test-duration { background-color: var(--light-gray-color); padding: 4px 10px; border-radius: 12px; font-size: 0.9em;}
1519
+
1520
+ .status-badge {
1521
+ padding: 5px; border-radius: 6px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase;
1522
+ min-width: 70px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
1523
+ }
1524
+ .status-badge.status-passed { background-color: var(--success-color); }
1525
+ .status-badge.status-failed { background-color: var(--danger-color); }
1526
+ .status-badge.status-skipped { background-color: var(--warning-color); }
1527
+ .status-badge.status-unknown { background-color: var(--dark-gray-color); }
1528
+
1529
+ .tag { display: inline-block; background: linear-gradient( #fff, #333, #000); color: #fff; padding: 3px 10px; border-radius: 12px; font-size: 0.85em; margin-right: 6px; font-weight: 400; }
1530
+
1531
+ .test-case-content { display: none; padding: 20px; border-top: 1px solid var(--border-color); background-color: #fcfdff; }
1532
+ .test-case-content h4 { margin-top: 22px; margin-bottom: 14px; font-size: 1.15em; color: var(--primary-color); }
1533
+ .test-case-content p { margin-bottom: 10px; font-size: 1em; }
1534
+ .test-error-summary { margin-bottom: 20px; padding: 14px; background-color: rgba(244,67,54,0.05); border: 1px solid rgba(244,67,54,0.2); border-left: 4px solid var(--danger-color); border-radius: 4px; }
1535
+ .test-error-summary h4 { color: var(--danger-color); margin-top:0;}
1536
+ .test-error-summary pre { white-space: pre-wrap; word-break: break-all; color: var(--danger-color); font-size: 0.95em;}
1537
+
1538
+ .steps-list { margin: 18px 0; }
1539
+ .step-item { margin-bottom: 8px; padding-left: calc(var(--depth, 0) * 28px); }
1540
+ .step-header {
1541
+ display: flex; align-items: center; cursor: pointer;
1542
+ padding: 10px 14px; border-radius: 6px; background-color: #fff;
1543
+ border: 1px solid var(--light-gray-color);
1544
+ transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
1545
+ }
1546
+ .step-header:hover { background-color: #f0f2f5; border-color: var(--medium-gray-color); box-shadow: var(--box-shadow-inset); }
1547
+ .step-icon { margin-right: 12px; width: 20px; text-align: center; font-size: 1.1em; }
1548
+ .step-title { flex: 1; font-size: 1em; }
1549
+ .step-duration { color: var(--dark-gray-color); font-size: 0.9em; }
1550
+ .step-details { display: none; padding: 14px; margin-top: 8px; background: #fdfdfd; border-radius: 6px; font-size: 0.95em; border: 1px solid var(--light-gray-color); }
1551
+ .step-info { margin-bottom: 8px; }
1552
+ .step-error { color: var(--danger-color); margin-top: 12px; padding: 14px; background: rgba(244,67,54,0.05); border-radius: 4px; font-size: 0.95em; border-left: 3px solid var(--danger-color); }
1553
+ .step-error pre.stack-trace { margin-top: 10px; padding: 12px; background-color: rgba(0,0,0,0.03); border-radius: 4px; font-size:0.9em; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
1554
+ .step-hook { background-color: rgba(33,150,243,0.04); border-left: 3px solid var(--info-color) !important; }
1555
+ .step-hook .step-title { font-style: italic; color: var(--info-color)}
1556
+ .nested-steps { margin-top: 12px; }
1557
+
1558
+ .attachments-section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--light-gray-color); }
1559
+ .attachments-section h4 { margin-top: 0; margin-bottom: 20px; font-size: 1.1em; color: var(--text-color); }
1560
+ .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
1561
+ .attachment-item {
1562
+ border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: #fff;
1563
+ box-shadow: var(--box-shadow-light); overflow: hidden; display: flex; flex-direction: column;
1564
+ transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
1565
+ }
1566
+ .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
1567
+ .attachment-item img {
1568
+ width: 100%; height: 180px; object-fit: cover; display: block;
1569
+ border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease;
1570
+ }
1571
+ .attachment-item a:hover img { opacity: 0.85; }
1572
+ .attachment-caption {
1573
+ padding: 12px 15px; font-size: 0.9em; text-align: center;
1574
+ color: var(--text-color-secondary); word-break: break-word; background-color: var(--light-gray-color);
1575
+ }
1576
+ .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
1577
+ .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
1578
+ .code-section pre { background-color: #2d2d2d; color: #f0f0f0; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 0.95em; line-height:1.6;}
1579
+
1580
+ .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
1581
+ /* Removed D3 specific .chart-axis, .main-chart-title, .chart-line.* rules */
1582
+ /* Highcharts styles its elements with classes like .highcharts-axis, .highcharts-title etc. */
1583
+
1584
+ .test-history-container h2.tab-main-title { font-size: 1.6em; margin-bottom: 18px; color: var(--primary-color); border-bottom: 1px solid var(--border-color); padding-bottom: 12px;}
1585
+ .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
1586
+ .test-history-card {
1587
+ background: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius);
1588
+ padding: 22px; box-shadow: var(--box-shadow-light); display: flex; flex-direction: column;
1589
+ }
1590
+ .test-history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--light-gray-color); }
1591
+ .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1592
+ .test-history-header p { font-weight: 500 }
1593
+ .test-history-trend { margin-bottom: 20px; min-height: 110px; }
1594
+ .test-history-trend div[id^="testHistoryChart-"] { /* Highcharts container for history */
1595
+ display: block; margin: 0 auto; max-width:100%; height: 100px; width: 320px; /* Match JS config */
1596
+ }
1597
+ /* .test-history-trend .small-axis text {font-size: 11px;} Removed D3 specific */
1598
+ .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
1599
+ .test-history-details-collapsible summary:hover {text-decoration: underline;}
1600
+ .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
1601
+ .test-history-details th, .test-history-details td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--light-gray-color); }
1602
+ .test-history-details th { background-color: var(--light-gray-color); font-weight: 600; }
1603
+ .status-badge-small {
1604
+ padding: 3px 7px; border-radius: 4px; font-size: 0.8em; font-weight: 600;
1605
+ color: white; text-transform: uppercase; display: inline-block;
1606
+ }
1607
+ .status-badge-small.status-passed { background-color: var(--success-color); }
1608
+ .status-badge-small.status-failed { background-color: var(--danger-color); }
1609
+ .status-badge-small.status-skipped { background-color: var(--warning-color); }
1610
+ .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
1611
+
1612
+ .no-data, .no-tests, .no-steps, .no-data-chart {
1613
+ padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em;
1614
+ background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0;
1615
+ border: 1px dashed var(--medium-gray-color);
1616
+ }
1617
+ .no-data-chart {font-size: 0.95em; padding: 18px;}
1618
+
1619
+ #test-ai iframe { border: 1px solid var(--border-color); width: 100%; height: 85vh; border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); }
1620
+ #test-ai p {margin-bottom: 18px; font-size: 1em; color: var(--text-color-secondary);}
1621
+ pre .stdout-log { background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1622
+ pre .stderr-log { background-color: #2d2d2d; color: indianred; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1623
+
1624
+ .trace-preview {
1625
+ padding: 1rem;
1626
+ text-align: center;
1627
+ background: #f5f5f5;
1628
+ border-bottom: 1px solid #e1e1e1;
1629
+ }
1630
+
1631
+ .trace-icon {
1632
+ font-size: 2rem;
1633
+ display: block;
1634
+ margin-bottom: 0.5rem;
1635
+ }
1636
+
1637
+ .trace-name {
1638
+ word-break: break-word;
1639
+ font-size: 0.9rem;
1640
+ }
1641
+
1642
+ .trace-actions {
1643
+ display: flex;
1644
+ gap: 0.5rem;
1645
+ }
1646
+
1647
+ .trace-actions a {
1648
+ flex: 1;
1649
+ text-align: center;
1650
+ padding: 0.25rem 0.5rem;
1651
+ font-size: 0.85rem;
1652
+ border-radius: 4px;
1653
+ text-decoration: none;
1654
+ background: cornflowerblue;
1655
+ color: aliceblue;
1656
+ }
1657
+
1658
+ .view-trace {
1659
+ background: #3182ce;
1660
+ color: white;
1661
+ }
1662
+
1663
+ .view-trace:hover {
1664
+ background: #2c5282;
1665
+ }
1666
+
1667
+ .download-trace {
1668
+ background: #e2e8f0;
1669
+ color: #2d3748;
1670
+ }
1671
+
1672
+ .download-trace:hover {
1673
+ background: #cbd5e0;
1674
+ }
1675
+
1676
+ .filters button.clear-filters-btn {
1677
+ background-color: var(--medium-gray-color); /* Or any other suitable color */
1678
+ color: var(--text-color);
1679
+ /* Add other styling as per your .filters button style if needed */
1680
+ }
1681
+
1682
+ .filters button.clear-filters-btn:hover {
1683
+ background-color: var(--dark-gray-color); /* Darker on hover */
1684
+ color: #fff;
1685
+ }
1686
+ @media (max-width: 1200px) {
1687
+ .trend-charts-row { grid-template-columns: 1fr; }
1688
+ }
1689
+ @media (max-width: 992px) {
1690
+ .dashboard-bottom-row { grid-template-columns: 1fr; }
1691
+ .pie-chart-wrapper div[id^="pieChart-"] { max-width: 350px; margin: 0 auto; }
1692
+ .filters input { min-width: 180px; }
1693
+ .filters select { min-width: 150px; }
1694
+ }
1695
+ @media (max-width: 768px) {
1696
+ body { font-size: 15px; }
1697
+ .container { margin: 10px; padding: 20px; }
1698
+ .header { flex-direction: column; align-items: flex-start; gap: 15px; }
1699
+ .header h1 { font-size: 1.6em; }
1700
+ .run-info { text-align: left; font-size:0.9em; }
1701
+ .tabs { margin-bottom: 25px;}
1702
+ .tab-button { padding: 12px 20px; font-size: 1.05em;}
1703
+ .dashboard-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px;}
1704
+ .summary-card .value {font-size: 2em;}
1705
+ .summary-card h3 {font-size: 0.95em;}
1706
+ .filters { flex-direction: column; padding: 18px; gap: 12px;}
1707
+ .filters input, .filters select, .filters button {width: 100%; box-sizing: border-box;}
1708
+ .test-case-header { flex-direction: column; align-items: flex-start; gap: 10px; padding: 14px; }
1709
+ .test-case-summary {gap: 10px;}
1710
+ .test-case-title {font-size: 1.05em;}
1711
+ .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;}
1712
+ .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;}
1713
+ .test-history-grid {grid-template-columns: 1fr;}
1714
+ .pie-chart-wrapper {min-height: auto;}
1715
+ }
1716
+ @media (max-width: 480px) {
1717
+ body {font-size: 14px;}
1718
+ .container {padding: 15px;}
1719
+ .header h1 {font-size: 1.4em;}
1720
+ #report-logo { height: 35px; width: 35px; }
1721
+ .tab-button {padding: 10px 15px; font-size: 1em;}
1722
+ .summary-card .value {font-size: 1.8em;}
1723
+ .attachments-grid {grid-template-columns: 1fr;}
1724
+ .step-item {padding-left: calc(var(--depth, 0) * 18px);}
1725
+ .test-case-content, .step-details {padding: 15px;}
1726
+ .trend-charts-row {gap: 20px;}
1727
+ .trend-chart {padding: 20px;}
1728
+ }
1729
+ </style>
1730
+ </head>
1731
+ <body>
1732
+ <div class="container">
1733
+ <header class="header">
1734
+ <div class="header-title">
1735
+ <img id="report-logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA3bDggNSA4LTUtOC01eiIgZmlsbD0iIzNmNTFiNSIvPjxwYXRoIGQ9Ik0xMiA2TDQgMTFsOCA1IDgtNS04LTV6IiBmaWxsPSIjNDI4NWY0Ii8+PHBhdGggZD0iTTEyIDEwbC04IDUgOCA1IDgtNS04LTV6IiBmaWxsPSIjM2Q1NWI0Ii8+PC9zdmc+" alt="Report Logo">
1736
+ <h1>Playwright Pulse Report</h1>
1737
+ </div>
1738
+ <div class="run-info">
1739
+ <strong>Run Date:</strong> ${formatDate(
1740
+ runSummary.timestamp
1741
+ )}<br>
1742
+ <strong>Total Duration:</strong> ${formatDuration(
1743
+ runSummary.duration
1744
+ )}
1745
+ </div>
1746
+ </header>
1747
+
1748
+ <div class="tabs">
1749
+ <button class="tab-button active" data-tab="dashboard">Dashboard</button>
1750
+ <button class="tab-button" data-tab="test-runs">Test Run Summary</button>
1751
+ <button class="tab-button" data-tab="test-history">Test History</button>
1752
+ <button class="tab-button" data-tab="test-ai">AI Analysis</button>
1753
+ </div>
1754
+
1755
+ <div id="dashboard" class="tab-content active">
1756
+ <div class="dashboard-grid">
1757
+ <div class="summary-card">
1758
+ <h3>Total Tests</h3><div class="value">${
1759
+ runSummary.totalTests
1760
+ }</div>
1761
+ </div>
1762
+ <div class="summary-card status-passed">
1763
+ <h3>Passed</h3><div class="value">${runSummary.passed}</div>
1764
+ <div class="trend-percentage">${passPercentage}%</div>
1765
+ </div>
1766
+ <div class="summary-card status-failed">
1767
+ <h3>Failed</h3><div class="value">${runSummary.failed}</div>
1768
+ <div class="trend-percentage">${failPercentage}%</div>
1769
+ </div>
1770
+ <div class="summary-card status-skipped">
1771
+ <h3>Skipped</h3><div class="value">${
1772
+ runSummary.skipped || 0
1773
+ }</div>
1774
+ <div class="trend-percentage">${skipPercentage}%</div>
1775
+ </div>
1776
+ <div class="summary-card">
1777
+ <h3>Avg. Test Time</h3><div class="value">${avgTestDuration}</div>
1778
+ </div>
1779
+ <div class="summary-card">
1780
+ <h3>Run Duration</h3><div class="value">${formatDuration(
1781
+ runSummary.duration
1782
+ )}</div>
1783
+ </div>
1784
+ </div>
1785
+ <div class="dashboard-bottom-row">
1786
+ ${generatePieChart(
1787
+ // Changed from generatePieChartD3
1788
+ [
1789
+ { label: "Passed", value: runSummary.passed },
1790
+ { label: "Failed", value: runSummary.failed },
1791
+ { label: "Skipped", value: runSummary.skipped || 0 },
1792
+ ],
1793
+ 400, // Default width
1794
+ 390 // Default height (adjusted for legend + title)
1795
+ )}
1796
+ ${generateSuitesWidget(suitesData)}
1797
+ </div>
1798
+ </div>
1799
+
1800
+ <div id="test-runs" class="tab-content">
1801
+ <div class="filters">
1802
+ <input type="text" id="filter-name" placeholder="Filter by test name/path..." style="border-color: black; border-style: outset;">
1803
+ <select id="filter-status">
1804
+ <option value="">All Statuses</option>
1805
+ <option value="passed">Passed</option>
1806
+ <option value="failed">Failed</option>
1807
+ <option value="skipped">Skipped</option>
1808
+ </select>
1809
+ <select id="filter-browser">
1810
+ <option value="">All Browsers</option>
1811
+ {/* Dynamically generated options will be here */}
1812
+ ${Array.from(
1813
+ new Set((results || []).map((test) => test.browser || "unknown"))
1814
+ )
1815
+ .map(
1816
+ (browser) =>
1817
+ `<option value="${sanitizeHTML(browser)}">${sanitizeHTML(
1818
+ browser
1819
+ )}</option>`
1820
+ )
1821
+ .join("")}
1822
+ </select>
1823
+ <button id="expand-all-tests">Expand All</button>
1824
+ <button id="collapse-all-tests">Collapse All</button>
1825
+ <button id="clear-run-summary-filters" class="clear-filters-btn">Clear Filters</button>
1826
+ </div>
1827
+ <div class="test-cases-list">
1828
+ ${generateTestCasesHTML()}
1829
+ </div>
1830
+ </div>
1831
+
1832
+ <div id="test-history" class="tab-content">
1833
+ <h2 class="tab-main-title">Execution Trends</h2>
1834
+ <div class="trend-charts-row">
1835
+ <div class="trend-chart">
1836
+ <h3 class="chart-title-header">Test Volume & Outcome Trends</h3>
1837
+ ${
1838
+ trendData && trendData.overall && trendData.overall.length > 0
1839
+ ? generateTestTrendsChart(trendData)
1840
+ : '<div class="no-data">Overall trend data not available for test counts.</div>'
1841
+ }
1842
+ </div>
1843
+ <div class="trend-chart">
1844
+ <h3 class="chart-title-header">Execution Duration Trends</h3>
1845
+ ${
1846
+ trendData && trendData.overall && trendData.overall.length > 0
1847
+ ? generateDurationTrendChart(trendData)
1848
+ : '<div class="no-data">Overall trend data not available for durations.</div>'
1849
+ }
1850
+ </div>
1851
+ </div>
1852
+ <h2 class="tab-main-title">Individual Test History</h2>
1853
+ ${
1854
+ trendData &&
1855
+ trendData.testRuns &&
1856
+ Object.keys(trendData.testRuns).length > 0
1857
+ ? generateTestHistoryContent(trendData)
1858
+ : '<div class="no-data">Individual test history data not available.</div>'
1859
+ }
1860
+ </div>
1861
+
1862
+ <div id="test-ai" class="tab-content">
1863
+ <iframe
1864
+ src="https://ai-test-analyser.netlify.app/"
1865
+ width="100%"
1866
+ height="100%"
1867
+ frameborder="0"
1868
+ allowfullscreen
1869
+ style="border: none; height: 100vh;">
1870
+ </iframe>
1871
+ </div>
1872
+ <footer style="
1873
+ padding: 0.5rem;
1874
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
1875
+ text-align: center;
1876
+ font-family: 'Segoe UI', system-ui, sans-serif;
1877
+ ">
1878
+ <div style="
1879
+ display: inline-flex;
1880
+ align-items: center;
1881
+ gap: 0.5rem;
1882
+ color: #333;
1883
+ font-size: 0.9rem;
1884
+ font-weight: 600;
1885
+ letter-spacing: 0.5px;
1886
+ ">
1887
+ <img width="48" height="48" src="https://img.icons8.com/emoji/48/index-pointing-at-the-viewer-light-skin-tone-emoji.png" alt="index-pointing-at-the-viewer-light-skin-tone-emoji"/>
1888
+ <span>Created by</span>
1889
+ <a href="https://github.com/Arghajit47"
1890
+ target="_blank"
1891
+ rel="noopener noreferrer"
1892
+ style="
1893
+ color: #7737BF;
1894
+ font-weight: 700;
1895
+ font-style: italic;
1896
+ text-decoration: none;
1897
+ transition: all 0.2s ease;
1898
+ "
1899
+ onmouseover="this.style.color='#BF5C37'"
1900
+ onmouseout="this.style.color='#7737BF'">
1901
+ Arghajit Singha
1902
+ </a>
1903
+ </div>
1904
+ <div style="
1905
+ margin-top: 0.5rem;
1906
+ font-size: 0.75rem;
1907
+ color: #666;
1908
+ ">
1909
+ Crafted with precision
1910
+ </div>
1911
+ </footer>
1912
+ </div>
1913
+
1914
+
1915
+ <script>
1916
+ // Ensure formatDuration is globally available
1917
+ if (typeof formatDuration === 'undefined') {
1918
+ function formatDuration(ms) {
1919
+ if (ms === undefined || ms === null || ms < 0) return "0.0s";
1920
+ return (ms / 1000).toFixed(1) + "s";
1921
+ }
1922
+ }
1923
+
1924
+ function initializeReportInteractivity() {
1925
+ const tabButtons = document.querySelectorAll('.tab-button');
1926
+ const tabContents = document.querySelectorAll('.tab-content');
1927
+ tabButtons.forEach(button => {
1928
+ button.addEventListener('click', () => {
1929
+ tabButtons.forEach(btn => btn.classList.remove('active'));
1930
+ tabContents.forEach(content => content.classList.remove('active'));
1931
+ button.classList.add('active');
1932
+ const tabId = button.getAttribute('data-tab');
1933
+ const activeContent = document.getElementById(tabId);
1934
+ if (activeContent) activeContent.classList.add('active');
1935
+ });
1936
+ });
1937
+
1938
+ // --- Test Run Summary Filters ---
1939
+ const nameFilter = document.getElementById('filter-name');
1940
+ const statusFilter = document.getElementById('filter-status');
1941
+ const browserFilter = document.getElementById('filter-browser');
1942
+ const clearRunSummaryFiltersBtn = document.getElementById('clear-run-summary-filters'); // Get the new button
1943
+
1944
+ function filterTestCases() {
1945
+ const nameValue = nameFilter ? nameFilter.value.toLowerCase() : "";
1946
+ const statusValue = statusFilter ? statusFilter.value : "";
1947
+ const browserValue = browserFilter ? browserFilter.value : "";
1948
+
1949
+ document.querySelectorAll('#test-runs .test-case').forEach(testCaseElement => {
1950
+ const titleElement = testCaseElement.querySelector('.test-case-title');
1951
+ const fullTestName = titleElement ? titleElement.getAttribute('title').toLowerCase() : "";
1952
+ const status = testCaseElement.getAttribute('data-status');
1953
+ const browser = testCaseElement.getAttribute('data-browser');
1954
+
1955
+ const nameMatch = fullTestName.includes(nameValue);
1956
+ const statusMatch = !statusValue || status === statusValue;
1957
+ const browserMatch = !browserValue || browser === browserValue;
1958
+
1959
+ testCaseElement.style.display = (nameMatch && statusMatch && browserMatch) ? '' : 'none';
1960
+ });
1961
+ }
1962
+ if(nameFilter) nameFilter.addEventListener('input', filterTestCases);
1963
+ if(statusFilter) statusFilter.addEventListener('change', filterTestCases);
1964
+ if(browserFilter) browserFilter.addEventListener('change', filterTestCases);
1965
+
1966
+ // Event listener for clearing Test Run Summary filters
1967
+ if (clearRunSummaryFiltersBtn) {
1968
+ clearRunSummaryFiltersBtn.addEventListener('click', () => {
1969
+ if (nameFilter) nameFilter.value = '';
1970
+ if (statusFilter) statusFilter.value = '';
1971
+ if (browserFilter) browserFilter.value = '';
1972
+ filterTestCases(); // Re-apply filters (which will show all)
1973
+ });
1974
+ }
1975
+
1976
+ // --- Test History Filters ---
1977
+ const historyNameFilter = document.getElementById('history-filter-name');
1978
+ const historyStatusFilter = document.getElementById('history-filter-status');
1979
+ const clearHistoryFiltersBtn = document.getElementById('clear-history-filters'); // Get the new button
1980
+
1981
+
1982
+ function filterTestHistoryCards() {
1983
+ const nameValue = historyNameFilter ? historyNameFilter.value.toLowerCase() : "";
1984
+ const statusValue = historyStatusFilter ? historyStatusFilter.value : "";
1985
+
1986
+ document.querySelectorAll('.test-history-card').forEach(card => {
1987
+ const testTitle = card.getAttribute('data-test-name').toLowerCase();
1988
+ const latestStatus = card.getAttribute('data-latest-status');
1989
+
1990
+ const nameMatch = testTitle.includes(nameValue);
1991
+ const statusMatch = !statusValue || latestStatus === statusValue;
1992
+
1993
+ card.style.display = (nameMatch && statusMatch) ? '' : 'none';
1994
+ });
1995
+ }
1996
+ if(historyNameFilter) historyNameFilter.addEventListener('input', filterTestHistoryCards);
1997
+ if(historyStatusFilter) historyStatusFilter.addEventListener('change', filterTestHistoryCards);
1998
+
1999
+ // Event listener for clearing Test History filters
2000
+ if (clearHistoryFiltersBtn) {
2001
+ clearHistoryFiltersBtn.addEventListener('click', () => {
2002
+ if (historyNameFilter) historyNameFilter.value = '';
2003
+ if (historyStatusFilter) historyStatusFilter.value = '';
2004
+ filterTestHistoryCards(); // Re-apply filters (which will show all)
2005
+ });
2006
+ }
2007
+
2008
+ // --- Expand/Collapse and Toggle Details Logic (remains the same) ---
2009
+ function toggleElementDetails(headerElement, contentSelector) {
2010
+ let contentElement;
2011
+ if (headerElement.classList.contains('test-case-header')) {
2012
+ contentElement = headerElement.parentElement.querySelector('.test-case-content');
2013
+ } else if (headerElement.classList.contains('step-header')) {
2014
+ contentElement = headerElement.nextElementSibling;
2015
+ if (!contentElement || !contentElement.matches(contentSelector || '.step-details')) {
2016
+ contentElement = null;
2017
+ }
2018
+ }
2019
+
2020
+ if (contentElement) {
2021
+ const isExpanded = contentElement.style.display === 'block';
2022
+ contentElement.style.display = isExpanded ? 'none' : 'block';
2023
+ headerElement.setAttribute('aria-expanded', String(!isExpanded));
2024
+ }
2025
+ }
2026
+
2027
+ document.querySelectorAll('#test-runs .test-case-header').forEach(header => {
2028
+ header.addEventListener('click', () => toggleElementDetails(header));
2029
+ });
2030
+ document.querySelectorAll('#test-runs .step-header').forEach(header => {
2031
+ header.addEventListener('click', () => toggleElementDetails(header, '.step-details'));
2032
+ });
2033
+
2034
+ const expandAllBtn = document.getElementById('expand-all-tests');
2035
+ const collapseAllBtn = document.getElementById('collapse-all-tests');
2036
+
2037
+ function setAllTestRunDetailsVisibility(displayMode, ariaState) {
2038
+ document.querySelectorAll('#test-runs .test-case-content').forEach(el => el.style.display = displayMode);
2039
+ document.querySelectorAll('#test-runs .step-details').forEach(el => el.style.display = displayMode);
2040
+ document.querySelectorAll('#test-runs .test-case-header[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', ariaState));
2041
+ document.querySelectorAll('#test-runs .step-header[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', ariaState));
2042
+ }
2043
+
2044
+ if (expandAllBtn) expandAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('block', 'true'));
2045
+ if (collapseAllBtn) collapseAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('none', 'false'));
2046
+ }
2047
+ document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
2048
+ </script>
2049
+ </body>
2050
+ </html>
2051
+ `;
2052
+ }
2053
+
2054
+ async function runScript(scriptPath) {
2055
+ return new Promise((resolve, reject) => {
2056
+ console.log(chalk.blue(`Executing script: ${scriptPath}...`));
2057
+ const process = fork(scriptPath, [], {
2058
+ stdio: "inherit",
2059
+ });
2060
+
2061
+ process.on("error", (err) => {
2062
+ console.error(chalk.red(`Failed to start script: ${scriptPath}`), err);
2063
+ reject(err);
2064
+ });
2065
+
2066
+ process.on("exit", (code) => {
2067
+ if (code === 0) {
2068
+ console.log(chalk.green(`Script ${scriptPath} finished successfully.`));
2069
+ resolve();
2070
+ } else {
2071
+ const errorMessage = `Script ${scriptPath} exited with code ${code}.`;
2072
+ console.error(chalk.red(errorMessage));
2073
+ reject(new Error(errorMessage));
2074
+ }
2075
+ });
2076
+ });
2077
+ }
2078
+
2079
+ async function main() {
2080
+ const __filename = fileURLToPath(import.meta.url);
2081
+ const __dirname = path.dirname(__filename);
2082
+
2083
+ // Script to archive current run to JSON history (this is your modified "generate-trend.mjs")
2084
+ const archiveRunScriptPath = path.resolve(
2085
+ __dirname,
2086
+ "generate-trend.mjs" // Keeping the filename as per your request
2087
+ );
2088
+
2089
+ const outputDir = path.resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
2090
+ const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE); // Current run's main JSON
2091
+ const reportHtmlPath = path.resolve(outputDir, DEFAULT_HTML_FILE);
2092
+
2093
+ const historyDir = path.join(outputDir, "history"); // Directory for historical JSON files
2094
+ const HISTORY_FILE_PREFIX = "trend-"; // Match prefix used in archiving script
2095
+ const MAX_HISTORY_FILES_TO_LOAD_FOR_REPORT = 15; // How many historical runs to show in the report
2096
+
2097
+ console.log(chalk.blue(`Starting static HTML report generation...`));
2098
+ console.log(chalk.blue(`Output directory set to: ${outputDir}`));
2099
+
2100
+ // Step 1: Ensure current run data is archived to the history folder
2101
+ try {
2102
+ await runScript(archiveRunScriptPath); // This script now handles JSON history
2103
+ console.log(
2104
+ chalk.green("Current run data archiving to history completed.")
2105
+ );
2106
+ } catch (error) {
2107
+ console.error(
2108
+ chalk.red(
2109
+ "Failed to archive current run data. Report might use stale or incomplete historical trends."
2110
+ ),
2111
+ error
2112
+ );
2113
+ // You might decide to proceed or exit depending on the importance of historical data
2114
+ }
2115
+
2116
+ // Step 2: Load current run's data (for non-trend sections of the report)
2117
+ let currentRunReportData; // Data for the run being reported
2118
+ try {
2119
+ const jsonData = await fs.readFile(reportJsonPath, "utf-8");
2120
+ currentRunReportData = JSON.parse(jsonData);
2121
+ if (
2122
+ !currentRunReportData ||
2123
+ typeof currentRunReportData !== "object" ||
2124
+ !currentRunReportData.results
2125
+ ) {
2126
+ throw new Error(
2127
+ "Invalid report JSON structure. 'results' field is missing or invalid."
2128
+ );
2129
+ }
2130
+ if (!Array.isArray(currentRunReportData.results)) {
2131
+ currentRunReportData.results = [];
2132
+ console.warn(
2133
+ chalk.yellow(
2134
+ "Warning: 'results' field in current run JSON was not an array. Treated as empty."
2135
+ )
2136
+ );
2137
+ }
2138
+ } catch (error) {
2139
+ console.error(
2140
+ chalk.red(
2141
+ `Critical Error: Could not read or parse main report JSON at ${reportJsonPath}: ${error.message}`
2142
+ )
2143
+ );
2144
+ process.exit(1); // Exit if the main report for the current run is missing/invalid
2145
+ }
2146
+
2147
+ // Step 3: Load historical data for trends
2148
+ let historicalRuns = []; // Array of past PlaywrightPulseReport objects
2149
+ try {
2150
+ await fs.access(historyDir); // Check if history directory exists
2151
+ const allHistoryFiles = await fs.readdir(historyDir);
2152
+
2153
+ const jsonHistoryFiles = allHistoryFiles
2154
+ .filter(
2155
+ (file) => file.startsWith(HISTORY_FILE_PREFIX) && file.endsWith(".json")
2156
+ )
2157
+ .map((file) => {
2158
+ const timestampPart = file
2159
+ .replace(HISTORY_FILE_PREFIX, "")
2160
+ .replace(".json", "");
2161
+ return {
2162
+ name: file,
2163
+ path: path.join(historyDir, file),
2164
+ timestamp: parseInt(timestampPart, 10),
2165
+ };
2166
+ })
2167
+ .filter((file) => !isNaN(file.timestamp))
2168
+ .sort((a, b) => b.timestamp - a.timestamp); // Sort newest first to easily pick the latest N
2169
+
2170
+ const filesToLoadForTrend = jsonHistoryFiles.slice(
2171
+ 0,
2172
+ MAX_HISTORY_FILES_TO_LOAD_FOR_REPORT
2173
+ );
2174
+
2175
+ for (const fileMeta of filesToLoadForTrend) {
2176
+ try {
2177
+ const fileContent = await fs.readFile(fileMeta.path, "utf-8");
2178
+ const runJsonData = JSON.parse(fileContent); // Each file IS a PlaywrightPulseReport
2179
+ historicalRuns.push(runJsonData);
2180
+ } catch (fileReadError) {
2181
+ console.warn(
2182
+ chalk.yellow(
2183
+ `Could not read/parse history file ${fileMeta.name}: ${fileReadError.message}`
2184
+ )
2185
+ );
2186
+ }
2187
+ }
2188
+ // Reverse to have oldest first for chart data series (if charts expect chronological)
2189
+ historicalRuns.reverse();
2190
+ console.log(
2191
+ chalk.green(
2192
+ `Loaded ${historicalRuns.length} historical run(s) for trend analysis.`
2193
+ )
2194
+ );
2195
+ } catch (error) {
2196
+ if (error.code === "ENOENT") {
2197
+ console.warn(
2198
+ chalk.yellow(
2199
+ `History directory '${historyDir}' not found. No historical trends will be displayed.`
2200
+ )
2201
+ );
2202
+ } else {
2203
+ console.warn(
2204
+ chalk.yellow(
2205
+ `Error loading historical data from '${historyDir}': ${error.message}`
2206
+ )
2207
+ );
2208
+ }
2209
+ }
2210
+
2211
+ // Step 4: Prepare trendData object in the format expected by chart functions
2212
+ const trendData = {
2213
+ overall: [], // For overall run summaries (passed, failed, skipped, duration over time)
2214
+ testRuns: {}, // For individual test history (key: "test run <run_timestamp_ms>", value: array of test result summaries)
2215
+ };
2216
+
2217
+ if (historicalRuns.length > 0) {
2218
+ historicalRuns.forEach((histRunReport) => {
2219
+ // histRunReport is a full PlaywrightPulseReport object from a past run
2220
+ if (histRunReport.run) {
2221
+ // Ensure timestamp is a Date object for correct sorting/comparison later if needed by charts
2222
+ const runTimestamp = new Date(histRunReport.run.timestamp);
2223
+ trendData.overall.push({
2224
+ runId: runTimestamp.getTime(), // Use timestamp as a unique ID for this context
2225
+ timestamp: runTimestamp,
2226
+ duration: histRunReport.run.duration,
2227
+ totalTests: histRunReport.run.totalTests,
2228
+ passed: histRunReport.run.passed,
2229
+ failed: histRunReport.run.failed,
2230
+ skipped: histRunReport.run.skipped || 0,
2231
+ });
2232
+
2233
+ // For generateTestHistoryContent
2234
+ if (histRunReport.results && Array.isArray(histRunReport.results)) {
2235
+ const runKeyForTestHistory = `test run ${runTimestamp.getTime()}`; // Use timestamp to key test runs
2236
+ trendData.testRuns[runKeyForTestHistory] = histRunReport.results.map(
2237
+ (test) => ({
2238
+ testName: test.name, // Full test name path
2239
+ duration: test.duration,
2240
+ status: test.status,
2241
+ timestamp: new Date(test.startTime), // Assuming test.startTime exists and is what you need
2242
+ })
2243
+ );
2244
+ }
2245
+ }
2246
+ });
2247
+ // Ensure trendData.overall is sorted by timestamp if not already
2248
+ trendData.overall.sort(
2249
+ (a, b) => a.timestamp.getTime() - b.timestamp.getTime()
2250
+ );
2251
+ }
2252
+
2253
+ // Step 5: Generate and write HTML
2254
+ try {
2255
+ // currentRunReportData is for the main content (test list, summary cards of *this* run)
2256
+ // trendData is for the historical charts and test history section
2257
+ const htmlContent = generateHTML(currentRunReportData, trendData);
2258
+ await fs.writeFile(reportHtmlPath, htmlContent, "utf-8");
2259
+ console.log(
2260
+ chalk.green.bold(
2261
+ `🎉 Pulse report generated successfully at: ${reportHtmlPath}`
2262
+ )
2263
+ );
2264
+ console.log(chalk.gray(`(You can open this file in your browser)`));
2265
+ } catch (error) {
2266
+ console.error(chalk.red(`Error generating HTML report: ${error.message}`));
2267
+ console.error(chalk.red(error.stack)); // Log full stack for HTML generation errors
2268
+ process.exit(1);
2269
+ }
2270
+ }
2271
+
2272
+ // Make sure main() is called at the end of your script
2273
+ main().catch((err) => {
2274
+ console.error(
2275
+ chalk.red.bold(`Unhandled error during script execution: ${err.message}`)
2276
+ );
2277
+ console.error(err.stack);
2278
+ process.exit(1);
2279
+ });