@arghajit/playwright-pulse-report 0.2.0 → 0.2.2

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