@arghajit/playwright-pulse-report 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,10 @@ import * as fs from "fs/promises";
4
4
  import path from "path";
5
5
  import * as d3 from "d3";
6
6
  import { JSDOM } from "jsdom";
7
+ import * as XLSX from "xlsx";
8
+ import { fork } from "child_process"; // Add this
9
+ import { fileURLToPath } from "url"; // Add this for resolving path in ESM
10
+
7
11
  // Use dynamic import for chalk as it's ESM only
8
12
  let chalk;
9
13
  try {
@@ -27,1193 +31,1722 @@ const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
27
31
 
28
32
  // Helper functions
29
33
  function sanitizeHTML(str) {
34
+ // CORRECTED VERSION
30
35
  if (str === null || str === undefined) return "";
31
36
  return String(str)
32
- .replace(/&/g, "&")
33
- .replace(/</g, "&lt;")
34
- .replace(/>/g, "&gt;")
35
- .replace(/"/g, "&quot;")
36
- .replace(/'/g, "&#039;");
37
+ .replace(/&/g, "&")
38
+ .replace(/</g, "<")
39
+ .replace(/>/g, ">")
40
+ .replace(/"/g, `"`)
41
+ .replace(/'/g, "'");
42
+ }
43
+ function capitalize(str) {
44
+ if (!str) return ""; // Handle empty string
45
+ return str[0].toUpperCase() + str.slice(1).toLowerCase();
37
46
  }
38
47
 
48
+ // User-provided formatDuration function
39
49
  function formatDuration(ms) {
40
50
  if (ms === undefined || ms === null || ms < 0) return "0.0s";
41
51
  return (ms / 1000).toFixed(1) + "s";
42
52
  }
43
53
 
54
+ function generateTestTrendsChart(trendData) {
55
+ if (!trendData || !trendData.overall || trendData.overall.length === 0) {
56
+ return '<div class="no-data">No overall trend data available for test counts.</div>';
57
+ }
58
+
59
+ const { document } = new JSDOM().window;
60
+ const body = d3.select(document.body);
61
+
62
+ const legendHeight = 60;
63
+ const margin = { top: 30, right: 20, bottom: 50 + legendHeight, left: 50 };
64
+ const width = 600 - margin.left - margin.right;
65
+ const height = 350 - margin.top - margin.bottom;
66
+
67
+ const svg = body
68
+ .append("svg")
69
+ .attr(
70
+ "viewBox",
71
+ `0 0 ${width + margin.left + margin.right} ${
72
+ height + margin.top + margin.bottom
73
+ }`
74
+ )
75
+ .attr("preserveAspectRatio", "xMidYMid meet");
76
+
77
+ const chart = svg
78
+ .append("g")
79
+ .attr("transform", `translate(${margin.left},${margin.top})`);
80
+
81
+ const runs = trendData.overall;
82
+ const testCounts = runs.map((r) => r.totalTests);
83
+ const passedCounts = runs.map((r) => r.passed);
84
+ const failedCounts = runs.map((r) => r.failed);
85
+ const skippedCounts = runs.map((r) => r.skipped || 0);
86
+
87
+ const yMax = d3.max(
88
+ [testCounts, passedCounts, failedCounts, skippedCounts].flat()
89
+ );
90
+ const x = d3
91
+ .scalePoint()
92
+ .domain(runs.map((_, i) => i + 1))
93
+ .range([0, width])
94
+ .padding(0.5);
95
+ const y = d3
96
+ .scaleLinear()
97
+ .domain([0, yMax > 0 ? yMax * 1.1 : 10])
98
+ .range([height, 0]);
99
+
100
+ const xAxis = d3.axisBottom(x).tickFormat((d) => `Run ${d}`);
101
+ chart
102
+ .append("g")
103
+ .attr("class", "chart-axis x-axis")
104
+ .attr("transform", `translate(0,${height})`)
105
+ .call(xAxis);
106
+ chart.append("g").attr("class", "chart-axis y-axis").call(d3.axisLeft(y));
107
+
108
+ const lineGenerator = d3
109
+ .line()
110
+ .x((_, i) => x(i + 1))
111
+ .y((d) => y(d))
112
+ .curve(d3.curveMonotoneX);
113
+ const areaGenerator = d3
114
+ .area()
115
+ .x((_, i) => x(i + 1))
116
+ .y0(height)
117
+ .curve(d3.curveMonotoneX);
118
+
119
+ // ✅ Add gradient defs
120
+ const defs = svg.append("defs");
121
+
122
+ const gradients = [
123
+ { id: "totalGradient", color: "var(--primary-color)" },
124
+ { id: "passedGradient", color: "var(--success-color)" },
125
+ { id: "failedGradient", color: "var(--danger-color)" },
126
+ { id: "skippedGradient", color: "var(--warning-color)" },
127
+ ];
128
+
129
+ gradients.forEach(({ id, color }) => {
130
+ const gradient = defs
131
+ .append("linearGradient")
132
+ .attr("id", id)
133
+ .attr("x1", "0%")
134
+ .attr("y1", "0%")
135
+ .attr("x2", "0%")
136
+ .attr("y2", "100%");
137
+ gradient
138
+ .append("stop")
139
+ .attr("offset", "0%")
140
+ .attr("stop-color", color)
141
+ .attr("stop-opacity", 0.4);
142
+ gradient
143
+ .append("stop")
144
+ .attr("offset", "100%")
145
+ .attr("stop-color", color)
146
+ .attr("stop-opacity", 0);
147
+ });
148
+
149
+ // ✅ Render area fills
150
+ chart
151
+ .append("path")
152
+ .datum(testCounts)
153
+ .attr("fill", "url(#totalGradient)")
154
+ .attr(
155
+ "d",
156
+ areaGenerator.y1((d) => y(d))
157
+ );
158
+ chart
159
+ .append("path")
160
+ .datum(passedCounts)
161
+ .attr("fill", "url(#passedGradient)")
162
+ .attr(
163
+ "d",
164
+ areaGenerator.y1((d) => y(d))
165
+ );
166
+ chart
167
+ .append("path")
168
+ .datum(failedCounts)
169
+ .attr("fill", "url(#failedGradient)")
170
+ .attr(
171
+ "d",
172
+ areaGenerator.y1((d) => y(d))
173
+ );
174
+ chart
175
+ .append("path")
176
+ .datum(skippedCounts)
177
+ .attr("fill", "url(#skippedGradient)")
178
+ .attr(
179
+ "d",
180
+ areaGenerator.y1((d) => y(d))
181
+ );
182
+
183
+ // ✅ Render lines
184
+ chart
185
+ .append("path")
186
+ .datum(testCounts)
187
+ .attr("class", "chart-line total-line")
188
+ .attr("d", lineGenerator);
189
+ chart
190
+ .append("path")
191
+ .datum(passedCounts)
192
+ .attr("class", "chart-line passed-line")
193
+ .attr("d", lineGenerator);
194
+ chart
195
+ .append("path")
196
+ .datum(failedCounts)
197
+ .attr("class", "chart-line failed-line")
198
+ .attr("d", lineGenerator);
199
+ chart
200
+ .append("path")
201
+ .datum(skippedCounts)
202
+ .attr("class", "chart-line skipped-line")
203
+ .attr("d", lineGenerator);
204
+
205
+ // ✅ Tooltip
206
+ const tooltip = body
207
+ .append("div")
208
+ .attr("class", "chart-tooltip")
209
+ .style("opacity", 0)
210
+ .style("position", "absolute");
211
+
212
+ runs.forEach((run, i) => {
213
+ const categories = [
214
+ { type: "Total", count: run.totalTests, color: "var(--primary-color)" },
215
+ { type: "Passed", count: run.passed, color: "var(--success-color)" },
216
+ { type: "Failed", count: run.failed, color: "var(--danger-color)" },
217
+ {
218
+ type: "Skipped",
219
+ count: run.skipped || 0,
220
+ color: "var(--warning-color)",
221
+ },
222
+ ];
223
+
224
+ categories.forEach((category) => {
225
+ if (typeof category.count !== "number") return;
226
+
227
+ chart
228
+ .append("circle")
229
+ .attr("class", `hover-point hover-point-${category.type.toLowerCase()}`)
230
+ .attr("cx", x(i + 1))
231
+ .attr("cy", y(category.count))
232
+ .attr("r", 7)
233
+ .style("fill", "transparent")
234
+ .style("pointer-events", "all")
235
+ .on("mouseover", function (event) {
236
+ tooltip.transition().duration(150).style("opacity", 0.95);
237
+ tooltip
238
+ .html(
239
+ `
240
+ <strong>Run ${run.runId || i + 1} (${category.type})</strong><br>
241
+ Date: ${new Date(run.timestamp).toLocaleString()}<br>
242
+ ${category.type}: ${category.count}<br>
243
+ ---<br>
244
+ Total: ${run.totalTests} | Passed: ${run.passed}<br>
245
+ Failed: ${run.failed} | Skipped: ${run.skipped || 0}<br>
246
+ Duration: ${formatDuration(run.duration)}`
247
+ )
248
+ .style("left", `${event.pageX + 15}px`)
249
+ .style("top", `${event.pageY - 28}px`);
250
+
251
+ d3.selectAll(
252
+ `.visible-point-${category.type.toLowerCase()}[data-run-index="${i}"]`
253
+ )
254
+ .transition()
255
+ .duration(100)
256
+ .attr("r", 5.5)
257
+ .style("opacity", 1);
258
+ })
259
+ .on("mouseout", function () {
260
+ tooltip.transition().duration(300).style("opacity", 0);
261
+ d3.selectAll(
262
+ `.visible-point-${category.type.toLowerCase()}[data-run-index="${i}"]`
263
+ )
264
+ .transition()
265
+ .duration(100)
266
+ .attr("r", 4)
267
+ .style("opacity", 0.8);
268
+ });
269
+
270
+ chart
271
+ .append("circle")
272
+ .attr(
273
+ "class",
274
+ `visible-point visible-point-${category.type.toLowerCase()}`
275
+ )
276
+ .attr("data-run-index", i)
277
+ .attr("cx", x(i + 1))
278
+ .attr("cy", y(category.count))
279
+ .attr("r", 4)
280
+ .style("fill", category.color)
281
+ .style("opacity", 0.8)
282
+ .style("pointer-events", "none");
283
+ });
284
+ });
285
+
286
+ // ✅ Legend
287
+ const legendData = [
288
+ {
289
+ label: "Total",
290
+ colorClass: "total-line",
291
+ dotColor: "var(--primary-color)",
292
+ },
293
+ {
294
+ label: "Passed",
295
+ colorClass: "passed-line",
296
+ dotColor: "var(--success-color)",
297
+ },
298
+ {
299
+ label: "Failed",
300
+ colorClass: "failed-line",
301
+ dotColor: "var(--danger-color)",
302
+ },
303
+ {
304
+ label: "Skipped",
305
+ colorClass: "skipped-line",
306
+ dotColor: "var(--warning-color)",
307
+ },
308
+ ];
309
+
310
+ const legend = chart
311
+ .append("g")
312
+ .attr("class", "chart-legend-d3 chart-legend-bottom")
313
+ .attr(
314
+ "transform",
315
+ `translate(${width / 2 - (legendData.length * 80) / 2}, ${height + 40})`
316
+ );
317
+
318
+ legendData.forEach((item, i) => {
319
+ const row = legend.append("g").attr("transform", `translate(${i * 80}, 0)`);
320
+ row
321
+ .append("line")
322
+ .attr("x1", 0)
323
+ .attr("x2", 15)
324
+ .attr("y1", 5)
325
+ .attr("y2", 5)
326
+ .attr("class", `chart-line ${item.colorClass}`)
327
+ .style("stroke-width", 2.5);
328
+ row
329
+ .append("circle")
330
+ .attr("cx", 7.5)
331
+ .attr("cy", 5)
332
+ .attr("r", 3.5)
333
+ .style("fill", item.dotColor);
334
+ row
335
+ .append("text")
336
+ .attr("x", 22)
337
+ .attr("y", 10)
338
+ .text(item.label)
339
+ .style("font-size", "12px");
340
+ });
341
+
342
+ return `<div class="trend-chart-container">${body.html()}</div>`;
343
+ }
344
+
345
+ function generateDurationTrendChart(trendData) {
346
+ if (!trendData || !trendData.overall || trendData.overall.length === 0) {
347
+ return '<div class="no-data">No overall trend data available for durations.</div>';
348
+ }
349
+
350
+ const { document } = new JSDOM().window;
351
+ const body = d3.select(document.body);
352
+
353
+ const legendHeight = 30;
354
+ const margin = { top: 30, right: 20, bottom: 50 + legendHeight, left: 50 };
355
+ const width = 600 - margin.left - margin.right;
356
+ const height = 350 - margin.top - margin.bottom;
357
+
358
+ const svg = body
359
+ .append("svg")
360
+ .attr(
361
+ "viewBox",
362
+ `0 0 ${width + margin.left + margin.right} ${
363
+ height + margin.top + margin.bottom
364
+ }`
365
+ )
366
+ .attr("preserveAspectRatio", "xMidYMid meet");
367
+
368
+ const chart = svg
369
+ .append("g")
370
+ .attr("transform", `translate(${margin.left},${margin.top})`);
371
+
372
+ const runs = trendData.overall;
373
+ const durations = runs.map((run) => run.duration / 1000);
374
+
375
+ const x = d3
376
+ .scalePoint()
377
+ .domain(runs.map((_, i) => i + 1))
378
+ .range([0, width])
379
+ .padding(0.5);
380
+
381
+ const yMax = d3.max(durations);
382
+ const y = d3
383
+ .scaleLinear()
384
+ .domain([0, yMax > 0 ? yMax * 1.1 : 10])
385
+ .range([height, 0]);
386
+
387
+ const xAxis = d3.axisBottom(x).tickFormat((d) => `Run ${d}`);
388
+ chart
389
+ .append("g")
390
+ .attr("class", "chart-axis x-axis")
391
+ .attr("transform", `translate(0,${height})`)
392
+ .call(xAxis)
393
+ .selectAll("text")
394
+ .text((d) => `Run ${d}`);
395
+
396
+ chart
397
+ .append("g")
398
+ .attr("class", "chart-axis y-axis")
399
+ .call(d3.axisLeft(y).tickFormat((d) => `${d}s`));
400
+
401
+ // ✅ Gradient fill for area under the line
402
+ const defs = svg.append("defs");
403
+ const gradient = defs
404
+ .append("linearGradient")
405
+ .attr("id", "durationGradient")
406
+ .attr("x1", "0%")
407
+ .attr("y1", "0%")
408
+ .attr("x2", "0%")
409
+ .attr("y2", "100%");
410
+ gradient
411
+ .append("stop")
412
+ .attr("offset", "0%")
413
+ .attr("stop-color", "var(--accent-color-alt)")
414
+ .attr("stop-opacity", 0.4);
415
+ gradient
416
+ .append("stop")
417
+ .attr("offset", "100%")
418
+ .attr("stop-color", "var(--accent-color-alt)")
419
+ .attr("stop-opacity", 0);
420
+
421
+ // ✅ Line + area generators
422
+ const lineGenerator = d3
423
+ .line()
424
+ .x((_, i) => x(i + 1))
425
+ .y((d_val) => y(d_val))
426
+ .curve(d3.curveMonotoneX);
427
+
428
+ const areaGenerator = d3
429
+ .area()
430
+ .x((_, i) => x(i + 1))
431
+ .y0(height)
432
+ .y1((d_val) => y(d_val))
433
+ .curve(d3.curveMonotoneX);
434
+
435
+ chart
436
+ .append("path")
437
+ .datum(durations)
438
+ .attr("fill", "url(#durationGradient)")
439
+ .attr("d", areaGenerator);
440
+
441
+ chart
442
+ .append("path")
443
+ .datum(durations)
444
+ .attr("class", "chart-line duration-line")
445
+ .attr("d", lineGenerator);
446
+
447
+ // ✅ Tooltip handling
448
+ const tooltip = body
449
+ .append("div")
450
+ .attr("class", "chart-tooltip")
451
+ .style("opacity", 0);
452
+
453
+ runs.forEach((run, i) => {
454
+ chart
455
+ .append("circle")
456
+ .attr("class", "hover-point")
457
+ .attr("cx", x(i + 1))
458
+ .attr("cy", y(durations[i]))
459
+ .attr("r", 7)
460
+ .style("fill", "transparent")
461
+ .style("pointer-events", "all")
462
+ .on("mouseover", function (event) {
463
+ tooltip.transition().duration(150).style("opacity", 0.95);
464
+ tooltip
465
+ .html(
466
+ `
467
+ <strong>Run ${run.runId || i + 1}</strong><br>
468
+ Date: ${new Date(run.timestamp).toLocaleString()}<br>
469
+ Duration: ${formatDuration(run.duration)}<br>
470
+ Tests: ${run.totalTests}`
471
+ )
472
+ .style("left", `${event.pageX + 15}px`)
473
+ .style("top", `${event.pageY - 28}px`);
474
+ d3.select(`.visible-point-duration[data-run-index="${i}"]`)
475
+ .transition()
476
+ .duration(100)
477
+ .attr("r", 5.5)
478
+ .style("opacity", 1);
479
+ })
480
+ .on("mouseout", function () {
481
+ tooltip.transition().duration(300).style("opacity", 0);
482
+ d3.select(`.visible-point-duration[data-run-index="${i}"]`)
483
+ .transition()
484
+ .duration(100)
485
+ .attr("r", 4)
486
+ .style("opacity", 0.8);
487
+ });
488
+
489
+ chart
490
+ .append("circle")
491
+ .attr("class", "visible-point visible-point-duration")
492
+ .attr("data-run-index", i)
493
+ .attr("cx", x(i + 1))
494
+ .attr("cy", y(durations[i]))
495
+ .attr("r", 4)
496
+ .style("fill", "var(--accent-color-alt)")
497
+ .style("opacity", 0.8)
498
+ .style("pointer-events", "none");
499
+ });
500
+
501
+ const legend = chart
502
+ .append("g")
503
+ .attr("class", "chart-legend-d3 chart-legend-bottom")
504
+ .attr("transform", `translate(${width / 2 - 50}, ${height + 40})`);
505
+
506
+ const legendRow = legend.append("g");
507
+ legendRow
508
+ .append("line")
509
+ .attr("x1", 0)
510
+ .attr("x2", 15)
511
+ .attr("y1", 5)
512
+ .attr("y2", 5)
513
+ .attr("class", "chart-line duration-line")
514
+ .style("stroke-width", 2.5);
515
+ legendRow
516
+ .append("circle")
517
+ .attr("cx", 7.5)
518
+ .attr("cy", 5)
519
+ .attr("r", 3.5)
520
+ .style("fill", "var(--accent-color-alt)");
521
+ legendRow
522
+ .append("text")
523
+ .attr("x", 22)
524
+ .attr("y", 10)
525
+ .text("Duration")
526
+ .style("font-size", "12px");
527
+
528
+ chart
529
+ .append("text")
530
+ .attr("class", "chart-title main-chart-title")
531
+ .attr("x", width / 2)
532
+ .attr("y", -margin.top / 2 + 10)
533
+ .attr("text-anchor", "middle");
534
+
535
+ return `<div class="trend-chart-container">${body.html()}</div>`;
536
+ }
537
+
44
538
  function formatDate(dateStrOrDate) {
45
539
  if (!dateStrOrDate) return "N/A";
46
540
  try {
47
541
  const date = new Date(dateStrOrDate);
48
542
  if (isNaN(date.getTime())) return "Invalid Date";
49
- return date.toLocaleString();
543
+ // Using a more common and less verbose format
544
+ return (
545
+ date.toLocaleDateString(undefined, {
546
+ year: "2-digit",
547
+ month: "2-digit",
548
+ day: "2-digit",
549
+ }) +
550
+ " " +
551
+ date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
552
+ );
50
553
  } catch (e) {
51
- return "Invalid Date";
554
+ return "Invalid Date Format";
52
555
  }
53
556
  }
54
557
 
55
- function getStatusClass(status) {
56
- switch (status) {
57
- case "passed":
58
- return "status-passed";
59
- case "failed":
60
- return "status-failed";
61
- case "skipped":
62
- return "status-skipped";
63
- default:
64
- return "";
65
- }
66
- }
558
+ function generateTestHistoryChart(history) {
559
+ if (!history || history.length === 0)
560
+ return '<div class="no-data-chart">No data for chart</div>';
67
561
 
68
- function getStatusIcon(status) {
69
- switch (status) {
70
- case "passed":
71
- return "✅";
72
- case "failed":
73
- return "❌";
74
- case "skipped":
75
- return "⏭️";
76
- default:
77
- return "";
562
+ const { document } = new JSDOM().window;
563
+ const body = d3.select(document.body);
564
+
565
+ const width = 320;
566
+ const height = 100;
567
+ const margin = { top: 10, right: 10, bottom: 30, left: 40 };
568
+
569
+ const svg = body
570
+ .append("svg")
571
+ .attr("viewBox", `0 0 ${width} ${height}`)
572
+ .attr("preserveAspectRatio", "xMidYMid meet");
573
+
574
+ const chart = svg
575
+ .append("g")
576
+ .attr("transform", `translate(${margin.left},${margin.top})`);
577
+
578
+ const chartWidth = width - margin.left - margin.right;
579
+ const chartHeight = height - margin.top - margin.bottom;
580
+
581
+ const validHistory = history.filter(
582
+ (h) => h && typeof h.duration === "number" && h.duration >= 0
583
+ );
584
+ if (validHistory.length === 0)
585
+ return '<div class="no-data-chart">No valid data for chart</div>';
586
+
587
+ const maxDuration = d3.max(validHistory, (d) => d.duration);
588
+
589
+ const x = d3
590
+ .scalePoint()
591
+ .domain(validHistory.map((_, i) => i + 1))
592
+ .range([0, chartWidth])
593
+ .padding(0.5);
594
+
595
+ const y = d3
596
+ .scaleLinear()
597
+ .domain([0, maxDuration > 0 ? maxDuration * 1.1 : 1])
598
+ .range([chartHeight, 0]);
599
+
600
+ // Axes
601
+ const xAxis = d3.axisBottom(x).tickFormat((d) => `R${d}`);
602
+ chart
603
+ .append("g")
604
+ .attr("class", "chart-axis x-axis small-axis")
605
+ .attr("transform", `translate(0,${chartHeight})`)
606
+ .call(xAxis)
607
+ .selectAll("text")
608
+ .text((d) => `R${d}`);
609
+
610
+ chart
611
+ .append("g")
612
+ .attr("class", "chart-axis y-axis small-axis")
613
+ .call(
614
+ d3
615
+ .axisLeft(y)
616
+ .ticks(3)
617
+ .tickFormat((d) => formatDuration(d))
618
+ );
619
+
620
+ // Gradient
621
+ const defs = svg.append("defs");
622
+ const gradient = defs
623
+ .append("linearGradient")
624
+ .attr("id", "historyLineGradient")
625
+ .attr("x1", "0%")
626
+ .attr("y1", "0%")
627
+ .attr("x2", "0%")
628
+ .attr("y2", "100%");
629
+ gradient
630
+ .append("stop")
631
+ .attr("offset", "0%")
632
+ .attr("stop-color", "var(--accent-color)")
633
+ .attr("stop-opacity", 0.4);
634
+ gradient
635
+ .append("stop")
636
+ .attr("offset", "100%")
637
+ .attr("stop-color", "var(--accent-color)")
638
+ .attr("stop-opacity", 0);
639
+
640
+ // Line generator with smoothing
641
+ const lineGenerator = d3
642
+ .line()
643
+ .x((_, i) => x(i + 1))
644
+ .y((d) => y(d.duration))
645
+ .curve(d3.curveMonotoneX);
646
+
647
+ if (validHistory.length > 1) {
648
+ chart
649
+ .append("path")
650
+ .datum(validHistory)
651
+ .attr("class", "chart-line history-duration-line")
652
+ .attr("d", lineGenerator)
653
+ .style("stroke", "var(--accent-color)");
654
+
655
+ // Gradient area fill under line
656
+ const area = d3
657
+ .area()
658
+ .x((_, i) => x(i + 1))
659
+ .y0(chartHeight)
660
+ .y1((d) => y(d.duration))
661
+ .curve(d3.curveMonotoneX);
662
+
663
+ chart
664
+ .append("path")
665
+ .datum(validHistory)
666
+ .attr("d", area)
667
+ .attr("fill", "url(#historyLineGradient)");
78
668
  }
669
+
670
+ // Tooltip
671
+ const tooltip = body
672
+ .append("div")
673
+ .attr("class", "chart-tooltip")
674
+ .style("opacity", 0);
675
+
676
+ validHistory.forEach((run, i) => {
677
+ chart
678
+ .append("circle")
679
+ .attr("cx", x(i + 1))
680
+ .attr("cy", y(run.duration))
681
+ .attr("r", 6)
682
+ .style("fill", "transparent")
683
+ .style("pointer-events", "all")
684
+ .on("mouseover", function (event) {
685
+ tooltip.transition().duration(150).style("opacity", 0.95);
686
+ tooltip
687
+ .html(
688
+ `
689
+ <strong>Run ${run.runId || i + 1}</strong><br>
690
+ Status: <span class="status-badge-small-tooltip ${getStatusClass(
691
+ run.status
692
+ )}">${run.status.toUpperCase()}</span><br>
693
+ Duration: ${formatDuration(run.duration)}`
694
+ )
695
+ .style("left", `${event.pageX + 10}px`)
696
+ .style("top", `${event.pageY - 15}px`);
697
+ d3.select(this.nextSibling)
698
+ .transition()
699
+ .duration(100)
700
+ .attr("r", 4.5)
701
+ .style("opacity", 1);
702
+ })
703
+ .on("mouseout", function () {
704
+ tooltip.transition().duration(300).style("opacity", 0);
705
+ d3.select(this.nextSibling)
706
+ .transition()
707
+ .duration(100)
708
+ .attr("r", 3)
709
+ .style("opacity", 0.8);
710
+ });
711
+
712
+ chart
713
+ .append("circle")
714
+ .attr("class", "visible-point")
715
+ .attr("cx", x(i + 1))
716
+ .attr("cy", y(run.duration))
717
+ .attr("r", 3)
718
+ .style(
719
+ "fill",
720
+ run.status === "passed"
721
+ ? "var(--success-color)"
722
+ : run.status === "failed"
723
+ ? "var(--danger-color)"
724
+ : "var(--warning-color)"
725
+ )
726
+ .style("stroke", "#fff")
727
+ .style("stroke-width", "0.5px")
728
+ .style("opacity", 0.8)
729
+ .style("pointer-events", "none");
730
+ });
731
+
732
+ return body.html();
79
733
  }
80
734
 
81
- function generatePieChartD3(data, width = 300, height = 300) {
735
+ function generatePieChartD3(data, chartWidth = 300, chartHeight = 300) {
82
736
  const { document } = new JSDOM().window;
83
737
  const body = d3.select(document.body);
84
738
 
85
- // Calculate passed percentage
86
739
  const total = data.reduce((sum, d) => sum + d.value, 0);
87
- const passedPercentage =
88
- total > 0
89
- ? Math.round(
90
- ((data.find((d) => d.label === "Passed")?.value || 0) / total) * 100
91
- )
92
- : 0;
740
+ if (total === 0) {
741
+ return '<div class="no-data">No data for Test Distribution chart.</div>';
742
+ }
743
+ const passedPercentage = Math.round(
744
+ ((data.find((d) => d.label === "Passed")?.value || 0) / total) * 100
745
+ );
746
+
747
+ const legendItemHeight = 22;
748
+ const legendAreaHeight =
749
+ data.filter((d) => d.value > 0).length * legendItemHeight;
750
+ const effectiveChartHeight = chartHeight - legendAreaHeight - 10; // Space for legend below
93
751
 
94
- // Chart dimensions
95
- const radius = Math.min(width, height) / 2 - 50; // Reduced radius for legend space
96
- const legendRectSize = 15;
97
- const legendSpacing = 8;
752
+ const outerRadius = Math.min(chartWidth, effectiveChartHeight) / 2 - 10; // Adjusted radius for legend space
753
+ const innerRadius = outerRadius * 0.55;
98
754
 
99
- // Pie generator
100
755
  const pie = d3
101
756
  .pie()
102
757
  .value((d) => d.value)
103
758
  .sort(null);
104
- const arc = d3.arc().innerRadius(0).outerRadius(radius);
105
-
106
- // Colors
759
+ const arcGenerator = d3
760
+ .arc()
761
+ .innerRadius(innerRadius)
762
+ .outerRadius(outerRadius);
763
+
764
+ const colorMap = {
765
+ Passed: "var(--success-color)",
766
+ Failed: "var(--danger-color)",
767
+ Skipped: "var(--warning-color)",
768
+ };
107
769
  const color = d3
108
770
  .scaleOrdinal()
109
771
  .domain(data.map((d) => d.label))
110
- .range(["#4CAF50", "#F44336", "#FFC107"]);
772
+ .range(data.map((d) => colorMap[d.label] || "#ccc"));
111
773
 
112
- // Create SVG with more width for legend
113
774
  const svg = body
114
775
  .append("svg")
115
- .attr("width", width + 100) // Extra width for legend
116
- .attr("height", height)
776
+ .attr("width", chartWidth) // SVG width is just for the chart
777
+ .attr("height", chartHeight) // Full height including legend
778
+ .attr("viewBox", `0 0 ${chartWidth} ${chartHeight}`)
779
+ .attr("preserveAspectRatio", "xMidYMid meet");
780
+
781
+ const chartGroup = svg
117
782
  .append("g")
118
- .attr("transform", `translate(${width / 2},${height / 2})`);
783
+ .attr(
784
+ "transform",
785
+ `translate(${chartWidth / 2}, ${effectiveChartHeight / 2 + 5})`
786
+ ); // Centered in available chart area
119
787
 
120
- // Tooltip setup
121
788
  const tooltip = body
122
789
  .append("div")
123
- .style("opacity", 0)
124
- .style("position", "absolute")
125
- .style("background", "white")
126
- .style("padding", "5px 10px")
127
- .style("border-radius", "4px")
128
- .style("box-shadow", "0 2px 5px rgba(0,0,0,0.1)");
129
-
130
- // Draw pie slices
131
- const arcs = svg
132
- .selectAll(".arc")
133
- .data(pie(data))
134
- .enter()
135
- .append("g")
136
- .attr("class", "arc");
790
+ .attr("class", "chart-tooltip")
791
+ .style("opacity", 0);
137
792
 
138
- arcs
793
+ chartGroup
794
+ .selectAll(".arc-path")
795
+ .data(pie(data.filter((d) => d.value > 0))) // Filter out zero-value slices for cleaner chart
796
+ .enter()
139
797
  .append("path")
140
- .attr("d", arc)
798
+ .attr("class", "arc-path")
799
+ .attr("d", arcGenerator)
141
800
  .attr("fill", (d) => color(d.data.label))
142
- .style("stroke", "#fff")
143
- .style("stroke-width", 2)
801
+ .style("stroke", "var(--card-background-color)")
802
+ .style("stroke-width", 3)
144
803
  .on("mouseover", function (event, d) {
145
- tooltip.transition().style("opacity", 1);
804
+ d3.select(this)
805
+ .transition()
806
+ .duration(150)
807
+ .attr(
808
+ "d",
809
+ d3
810
+ .arc()
811
+ .innerRadius(innerRadius)
812
+ .outerRadius(outerRadius + 6)
813
+ );
814
+ tooltip.transition().duration(150).style("opacity", 0.95);
146
815
  tooltip
147
816
  .html(
148
817
  `${d.data.label}: ${d.data.value} (${Math.round(
149
818
  (d.data.value / total) * 100
150
819
  )}%)`
151
820
  )
152
- .style("left", event.pageX + 10 + "px")
821
+ .style("left", event.pageX + 15 + "px")
153
822
  .style("top", event.pageY - 28 + "px");
154
823
  })
155
- .on("mouseout", () => tooltip.transition().style("opacity", 0));
824
+ .on("mouseout", function (event, d) {
825
+ d3.select(this).transition().duration(150).attr("d", arcGenerator);
826
+ tooltip.transition().duration(300).style("opacity", 0);
827
+ });
156
828
 
157
- // Center percentage
158
- svg
829
+ chartGroup
159
830
  .append("text")
831
+ .attr("class", "pie-center-percentage")
160
832
  .attr("text-anchor", "middle")
161
- .attr("dy", ".3em")
162
- .style("font-size", "24px")
163
- .style("font-weight", "bold")
833
+ .attr("dy", "0.05em")
164
834
  .text(`${passedPercentage}%`);
165
835
 
166
- // Legend - positioned to the right
836
+ chartGroup
837
+ .append("text")
838
+ .attr("class", "pie-center-label")
839
+ .attr("text-anchor", "middle")
840
+ .attr("dy", "1.3em")
841
+ .text("Passed");
842
+
167
843
  const legend = svg
168
- .selectAll(".legend")
169
- .data(color.domain())
170
- .enter()
171
844
  .append("g")
172
- .attr("class", "legend")
845
+ .attr("class", "pie-chart-legend-d3 chart-legend-bottom")
173
846
  .attr(
174
847
  "transform",
175
- (d, i) =>
176
- `translate(${radius + 20},${i * (legendRectSize + legendSpacing) - 40})`
177
- ); // Moved right
848
+ `translate(${chartWidth / 2}, ${effectiveChartHeight + 20})`
849
+ ); // Position legend below chart
178
850
 
179
- legend
180
- .append("rect")
181
- .attr("width", legendRectSize)
182
- .attr("height", legendRectSize)
183
- .style("fill", color)
184
- .style("stroke", color);
851
+ const legendItems = legend
852
+ .selectAll(".legend-item")
853
+ .data(data.filter((d) => d.value > 0))
854
+ .enter()
855
+ .append("g")
856
+ .attr("class", "legend-item")
857
+ // Position items horizontally, centering the block
858
+ .attr("transform", (d, i, nodes) => {
859
+ const numItems = nodes.length;
860
+ const totalLegendWidth = numItems * 90 - 10; // Approx width of all legend items
861
+ const startX = -totalLegendWidth / 2;
862
+ return `translate(${startX + i * 90}, 0)`; // 90 is approx width per item
863
+ });
185
864
 
186
- legend
865
+ legendItems
866
+ .append("rect")
867
+ .attr("width", 12)
868
+ .attr("height", 12)
869
+ .style("fill", (d) => color(d.label))
870
+ .attr("rx", 3)
871
+ .attr("ry", 3)
872
+ .attr("y", -6); // Align with text
873
+
874
+ legendItems
187
875
  .append("text")
188
- .attr("x", legendRectSize + 5)
189
- .attr("y", legendRectSize - 2)
190
- .text((d) => d)
876
+ .attr("x", 18)
877
+ .attr("y", 0)
878
+ .text((d) => `${d.label} (${d.value})`)
191
879
  .style("font-size", "12px")
192
- .style("text-anchor", "start");
880
+ .attr("dominant-baseline", "middle");
193
881
 
194
882
  return `
195
- <div class="pie-chart-container">
196
- <h3>Test Distribution Chart</h3>
883
+ <div class="pie-chart-wrapper">
884
+ <h3>Test Distribution</h3>
197
885
  ${body.html()}
198
- <style>
199
- .pie-chart-container {
200
- display: flex;
201
- justify-content: center;
202
- margin: 20px 0;
886
+ </div>`;
887
+ }
888
+
889
+ function generateTestHistoryContent(trendData) {
890
+ if (
891
+ !trendData ||
892
+ !trendData.testRuns ||
893
+ Object.keys(trendData.testRuns).length === 0
894
+ ) {
895
+ return '<div class="no-data">No historical test data available.</div>';
896
+ }
897
+
898
+ const allTestNamesAndPaths = new Map(); // Store {path: name, title: title}
899
+ Object.values(trendData.testRuns).forEach((run) => {
900
+ if (Array.isArray(run)) {
901
+ run.forEach((test) => {
902
+ if (test && test.testName && !allTestNamesAndPaths.has(test.testName)) {
903
+ const parts = test.testName.split(" > ");
904
+ const title = parts[parts.length - 1];
905
+ allTestNamesAndPaths.set(test.testName, title);
203
906
  }
204
- .pie-chart-container svg {
205
- display: block;
206
- margin: 0 auto;
907
+ });
908
+ }
909
+ });
910
+
911
+ if (allTestNamesAndPaths.size === 0) {
912
+ return '<div class="no-data">No historical test data found after processing.</div>';
913
+ }
914
+
915
+ const testHistory = Array.from(allTestNamesAndPaths.entries())
916
+ .map(([fullTestName, testTitle]) => {
917
+ const history = [];
918
+ (trendData.overall || []).forEach((overallRun, index) => {
919
+ const runKey = overallRun.runId
920
+ ? `test run ${overallRun.runId}`
921
+ : `test run ${index + 1}`;
922
+ const testRunForThisOverallRun = trendData.testRuns[runKey]?.find(
923
+ (t) => t && t.testName === fullTestName
924
+ );
925
+ if (testRunForThisOverallRun) {
926
+ history.push({
927
+ runId: overallRun.runId || index + 1,
928
+ status: testRunForThisOverallRun.status || "unknown",
929
+ duration: testRunForThisOverallRun.duration || 0,
930
+ timestamp:
931
+ testRunForThisOverallRun.timestamp ||
932
+ overallRun.timestamp ||
933
+ new Date(),
934
+ });
207
935
  }
208
- .pie-chart-container h3 {
209
- text-align: center;
210
- margin: 0 0 10px;
211
- font-size: 16px;
212
- color: var(--text-color);
213
- }
214
- </style>
936
+ });
937
+ return { fullTestName, testTitle, history };
938
+ })
939
+ .filter((item) => item.history.length > 0);
940
+
941
+ return `
942
+ <div class="test-history-container">
943
+ <div class="filters" style="border-color: black; border-style: groove;">
944
+ <input type="text" id="history-filter-name" placeholder="Search by test title..." style="border-color: black; border-style: outset;">
945
+ <select id="history-filter-status">
946
+ <option value="">All Statuses</option>
947
+ <option value="passed">Passed</option>
948
+ <option value="failed">Failed</option>
949
+ <option value="skipped">Skipped</option>
950
+ </select>
951
+ </div>
952
+
953
+ <div class="test-history-grid">
954
+ ${testHistory
955
+ .map((test) => {
956
+ const latestRun =
957
+ test.history.length > 0
958
+ ? test.history[test.history.length - 1]
959
+ : { status: "unknown" };
960
+ // For data-test-name, use the title for filtering as per input placeholder
961
+ return `
962
+ <div class="test-history-card" data-test-name="${sanitizeHTML(
963
+ test.testTitle.toLowerCase()
964
+ )}" data-latest-status="${latestRun.status}">
965
+ <div class="test-history-header">
966
+ <p title="${sanitizeHTML(test.testTitle)}">${capitalize(
967
+ sanitizeHTML(test.testTitle)
968
+ )}</p>
969
+ <span class="status-badge ${getStatusClass(latestRun.status)}">
970
+ ${latestRun.status.toUpperCase()}
971
+ </span>
972
+ </div>
973
+ <div class="test-history-trend">
974
+ ${generateTestHistoryChart(test.history)}
975
+ </div>
976
+ <details class="test-history-details-collapsible">
977
+ <summary>Show Run Details (${test.history.length})</summary>
978
+ <div class="test-history-details">
979
+ <table>
980
+ <thead><tr><th>Run</th><th>Status</th><th>Duration</th><th>Date</th></tr></thead>
981
+ <tbody>
982
+ ${test.history
983
+ .slice()
984
+ .reverse()
985
+ .map(
986
+ (run) => `
987
+ <tr>
988
+ <td>${run.runId}</td>
989
+ <td><span class="status-badge-small ${getStatusClass(
990
+ run.status
991
+ )}">${run.status.toUpperCase()}</span></td>
992
+ <td>${formatDuration(run.duration)}</td>
993
+ <td>${formatDate(run.timestamp)}</td>
994
+ </tr>`
995
+ )
996
+ .join("")}
997
+ </tbody>
998
+ </table>
999
+ </div>
1000
+ </details>
1001
+ </div>`;
1002
+ })
1003
+ .join("")}
1004
+ </div>
215
1005
  </div>
216
1006
  `;
217
1007
  }
218
1008
 
219
- // Process the JSON data to extract suites information
1009
+ function getStatusClass(status) {
1010
+ switch (String(status).toLowerCase()) {
1011
+ case "passed":
1012
+ return "status-passed";
1013
+ case "failed":
1014
+ return "status-failed";
1015
+ case "skipped":
1016
+ return "status-skipped";
1017
+ default:
1018
+ return "status-unknown";
1019
+ }
1020
+ }
1021
+
1022
+ function getStatusIcon(status) {
1023
+ switch (String(status).toLowerCase()) {
1024
+ case "passed":
1025
+ return "✅";
1026
+ case "failed":
1027
+ return "❌";
1028
+ case "skipped":
1029
+ return "⏭️";
1030
+ default:
1031
+ return "❓";
1032
+ }
1033
+ }
1034
+
220
1035
  function getSuitesData(results) {
221
1036
  const suitesMap = new Map();
1037
+ if (!results || results.length === 0) return [];
222
1038
 
223
1039
  results.forEach((test) => {
224
- const browser = test.browser; // Extract browser (chromium/firefox/webkit)
225
- const suiteName = test.suiteName;
1040
+ const browser = test.browser || "unknown";
1041
+ const suiteParts = test.name.split(" > ");
1042
+ // More robust suite name extraction: use file name if no clear suite, or parent dir if too generic
1043
+ let suiteNameCandidate = "Default Suite";
1044
+ if (suiteParts.length > 2) {
1045
+ // e.g. file > suite > test
1046
+ suiteNameCandidate = suiteParts[1];
1047
+ } else if (suiteParts.length > 1) {
1048
+ // e.g. file > test
1049
+ suiteNameCandidate = suiteParts[0]
1050
+ .split(path.sep)
1051
+ .pop()
1052
+ .replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
1053
+ } else {
1054
+ // Just file name or malformed
1055
+ suiteNameCandidate = test.name
1056
+ .split(path.sep)
1057
+ .pop()
1058
+ .replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
1059
+ }
1060
+ const suiteName = suiteNameCandidate;
226
1061
  const key = `${suiteName}|${browser}`;
227
1062
 
228
1063
  if (!suitesMap.has(key)) {
229
1064
  suitesMap.set(key, {
230
- id: test.id,
231
- name: `${suiteName} (${browser})`,
232
- status: test.status,
233
- count: 0,
1065
+ id: test.id || key,
1066
+ name: suiteName,
234
1067
  browser: browser,
1068
+ passed: 0,
1069
+ failed: 0,
1070
+ skipped: 0,
1071
+ count: 0,
1072
+ statusOverall: "passed",
235
1073
  });
236
1074
  }
237
- suitesMap.get(key).count++;
238
- });
1075
+ const suite = suitesMap.get(key);
1076
+ suite.count++;
1077
+ const currentStatus = String(test.status).toLowerCase();
1078
+ if (currentStatus && suite[currentStatus] !== undefined) {
1079
+ suite[currentStatus]++;
1080
+ }
239
1081
 
1082
+ if (currentStatus === "failed") {
1083
+ suite.statusOverall = "failed";
1084
+ } else if (
1085
+ currentStatus === "skipped" &&
1086
+ suite.statusOverall !== "failed"
1087
+ ) {
1088
+ suite.statusOverall = "skipped";
1089
+ }
1090
+ });
240
1091
  return Array.from(suitesMap.values());
241
1092
  }
242
1093
 
243
- // Generate suites widget (updated for your data)
244
1094
  function generateSuitesWidget(suitesData) {
1095
+ if (!suitesData || suitesData.length === 0) {
1096
+ return `<div class="suites-widget"><div class="suites-header"><h2>Test Suites</h2></div><div class="no-data">No suite data available.</div></div>`;
1097
+ }
245
1098
  return `
246
1099
  <div class="suites-widget">
247
1100
  <div class="suites-header">
248
1101
  <h2>Test Suites</h2>
249
- <div class="summary-badge">
1102
+ <span class="summary-badge">
250
1103
  ${suitesData.length} suites • ${suitesData.reduce(
251
1104
  (sum, suite) => sum + suite.count,
252
1105
  0
253
1106
  )} tests
254
- </div>
1107
+ </span>
255
1108
  </div>
256
-
257
1109
  <div class="suites-grid">
258
1110
  ${suitesData
259
1111
  .map(
260
1112
  (suite) => `
261
- <div class="suite-card ${suite.status}">
262
- <div class="suite-meta">
263
- <span class="browser-tag">${suite.name
264
- .split("(")
265
- .pop()
266
- .replace(")", "")}</span>
267
- <span class="test-count">${suite.count} test${
1113
+ <div class="suite-card status-${suite.statusOverall}">
1114
+ <div class="suite-card-header">
1115
+ <h3 class="suite-name" title="${sanitizeHTML(
1116
+ suite.name
1117
+ )} (${sanitizeHTML(suite.browser)})">${sanitizeHTML(suite.name)}</h3>
1118
+ <span class="browser-tag">${sanitizeHTML(suite.browser)}</span>
1119
+ </div>
1120
+ <div class="suite-card-body">
1121
+ <span class="test-count">${suite.count} test${
268
1122
  suite.count !== 1 ? "s" : ""
269
1123
  }</span>
1124
+ <div class="suite-stats">
1125
+ ${
1126
+ suite.passed > 0
1127
+ ? `<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>`
1128
+ : ""
1129
+ }
1130
+ ${
1131
+ suite.failed > 0
1132
+ ? `<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>`
1133
+ : ""
1134
+ }
1135
+ ${
1136
+ suite.skipped > 0
1137
+ ? `<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>`
1138
+ : ""
1139
+ }
1140
+ </div>
270
1141
  </div>
271
- <span class="browser-name">${suite.browser}</span>
272
- </div>
273
- `
1142
+ </div>`
274
1143
  )
275
1144
  .join("")}
276
1145
  </div>
1146
+ </div>`;
1147
+ }
277
1148
 
278
- <style>
279
- .suites-widget {
280
- background: linear-gradient(to bottom right, #f0f4ff, #ffffff);
281
- border-radius: 16px;
282
- padding: 10px;
283
- box-shadow: 0 4px 20px rgba(0,0,0,0.05);
284
- font-family: 'Segoe UI', Roboto, sans-serif;
285
- height: 100%;
286
- }
287
- span.browser-name {
288
- background-color: #265685;
289
- font-size: 0.875rem;
290
- color: #fff;
291
- padding: 3px;
292
- border-radius: 4px;
293
- }
294
-
295
- .suites-header {
296
- display: flex;
297
- align-items: center;
298
- gap: 16px;
299
- margin-bottom: 24px;
300
- }
301
-
302
- .suites-header h2 {
303
- font-size: 20px;
304
- font-weight: 600;
305
- margin: 0;
306
- color: #1a202c;
307
- }
308
-
309
- .summary-badge {
310
- background: #f8fafc;
311
- color: #64748b;
312
- padding: 4px 12px;
313
- border-radius: 12px;
314
- font-size: 14px;
315
- }
316
-
317
- .suites-grid {
318
- display: grid;
319
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
320
- gap: 16px;
321
- }
322
-
323
- .suite-card {
324
- background: #e6e6e6;
325
- border-radius: 12px;
326
- padding: 18px;
327
- border: 1px solid #f1f5f9;
328
- transition: all 0.2s ease;
329
- }
330
-
331
- .suite-card:hover {
332
- transform: translateY(-2px);
333
- box-shadow: 0 6px 12px rgba(0,0,0,0.08);
334
- border-color: #e2e8f0;
335
- }
1149
+ function generateHTML(reportData, trendData = null) {
1150
+ const { run, results } = reportData;
1151
+ const suitesData = getSuitesData(reportData.results || []);
1152
+ const runSummary = run || {
1153
+ totalTests: 0,
1154
+ passed: 0,
1155
+ failed: 0,
1156
+ skipped: 0,
1157
+ duration: 0,
1158
+ timestamp: new Date().toISOString(),
1159
+ };
336
1160
 
337
- .suite-meta {
338
- display: flex;
339
- justify-content: space-between;
340
- align-items: center;
341
- margin-bottom: 12px;
342
- }
1161
+ const totalTestsOr1 = runSummary.totalTests || 1;
1162
+ const passPercentage = Math.round((runSummary.passed / totalTestsOr1) * 100);
1163
+ const failPercentage = Math.round((runSummary.failed / totalTestsOr1) * 100);
1164
+ const skipPercentage = Math.round(
1165
+ ((runSummary.skipped || 0) / totalTestsOr1) * 100
1166
+ );
1167
+ const avgTestDuration =
1168
+ runSummary.totalTests > 0
1169
+ ? formatDuration(runSummary.duration / runSummary.totalTests)
1170
+ : "0.0s";
343
1171
 
344
- .browser-tag {
345
- font-size: 12px;
346
- font-weight: 600;
347
- color: #64748b;
348
- text-transform: uppercase;
349
- letter-spacing: 0.5px;
350
- }
1172
+ // Inside generate-static-report.mjs
351
1173
 
352
- .status-indicator {
353
- width: 12px;
354
- height: 12px;
355
- border-radius: 50%;
1174
+ function generateTestCasesHTML() {
1175
+ // Make sure this is within the scope where 'results' is defined
1176
+ if (!results || results.length === 0) {
1177
+ // Assuming 'results' is accessible here
1178
+ return '<div class="no-tests">No test results found in this run.</div>';
356
1179
  }
357
1180
 
358
- .status-indicator.passed {
359
- background: #2a9c68;
360
- box-shadow: 0 0 0 3px #ecfdf5;
361
- }
1181
+ return results
1182
+ .map((test, index) => {
1183
+ const browser = test.browser || "unknown";
1184
+ const testFileParts = test.name.split(" > ");
1185
+ const testTitle =
1186
+ testFileParts[testFileParts.length - 1] || "Unnamed Test";
362
1187
 
363
- .status-indicator.failed {
364
- background: #ef4444;
365
- box-shadow: 0 0 0 3px #fef2f2;
366
- }
367
-
368
- .status-indicator.skipped {
369
- background: #f59e0b;
370
- box-shadow: 0 0 0 3px #fffbeb;
371
- }
372
-
373
- .suite-card h3 {
374
- font-size: 16px;
375
- margin: 0 0 16px 0;
376
- color: #1e293b;
377
- white-space: nowrap;
378
- overflow: hidden;
379
- text-overflow: ellipsis;
380
- }
381
-
382
- .test-visualization {
383
- display: flex;
384
- align-items: center;
385
- gap: 12px;
386
- }
387
-
388
- .test-dots {
389
- padding: 4px;
390
- display: flex;
391
- flex-wrap: wrap;
392
- gap: 6px;
393
- flex-grow: 1;
394
- }
395
-
396
- .test-dot {
397
- width: 10px;
398
- height: 10px;
399
- border-radius: 50%;
400
- }
401
-
402
- .test-dot.passed {
403
- background: #2a9c68;
404
- }
405
-
406
- .test-dot.failed {
407
- background: #ef4444;
408
- }
409
-
410
- .test-dot.skipped {
411
- background: #f59e0b;
412
- }
413
-
414
- .test-count {
415
- font-size: 14px;
416
- color: #64748b;
417
- min-width: 60px;
418
- text-align: right;
419
- }
420
- </style>
421
- </div>
422
- `;
423
- }
424
-
425
- // Enhanced HTML generation with properly integrated CSS and JS
426
- function generateHTML(reportData) {
427
- const { run, results } = reportData;
428
- const suitesData = getSuitesData(reportData.results);
429
- const runSummary = run || {
430
- totalTests: 0,
431
- passed: 0,
432
- failed: 0,
433
- skipped: 0,
434
- duration: 0,
435
- timestamp: new Date(),
436
- };
437
-
438
- // Calculate additional metrics
439
- const passPercentage =
440
- runSummary.totalTests > 0
441
- ? Math.round((runSummary.passed / runSummary.totalTests) * 100)
442
- : 0;
443
- const failPercentage =
444
- runSummary.totalTests > 0
445
- ? Math.round((runSummary.failed / runSummary.totalTests) * 100)
446
- : 0;
447
- const skipPercentage =
448
- runSummary.totalTests > 0
449
- ? Math.round((runSummary.skipped / runSummary.totalTests) * 100)
450
- : 0;
451
- const avgTestDuration =
452
- runSummary.totalTests > 0
453
- ? formatDuration(runSummary.duration / runSummary.totalTests)
454
- : "0.0s";
455
-
456
- // Generate test cases HTML
457
- const generateTestCasesHTML = () => {
458
- if (!results || results.length === 0) {
459
- return '<div class="no-tests">No test results found</div>';
460
- }
461
-
462
- // Collect all unique tags and browsers
463
- const allTags = new Set();
464
- const allBrowsers = new Set();
465
-
466
- results.forEach((test) => {
467
- (test.tags || []).forEach((tag) => allTags.add(tag));
468
- const browserMatch = test.name.match(/ > (\w+) > /);
469
- if (browserMatch) allBrowsers.add(browserMatch[1]);
470
- });
471
-
472
- return results
473
- .map((test, index) => {
474
- const browser = test.browser || "unknown";
475
- const testName = test.name.split(" > ").pop() || test.name;
476
-
477
- // Generate steps HTML recursively
478
- const generateStepsHTML = (steps, depth = 0) => {
479
- if (!steps || steps.length === 0) return "";
480
-
481
- return steps
482
- .map((step) => {
483
- const hasNestedSteps = step.steps && step.steps.length > 0;
484
- const isHook = step.isHook;
485
- const stepClass = isHook ? "step-hook" : "";
486
- const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
1188
+ const generateStepsHTML = (steps, depth = 0) => {
1189
+ if (!steps || steps.length === 0)
1190
+ return "<div class='no-steps'>No steps recorded for this test.</div>";
1191
+ return steps
1192
+ .map((step) => {
1193
+ const hasNestedSteps = step.steps && step.steps.length > 0;
1194
+ const isHook = step.hookType;
1195
+ const stepClass = isHook
1196
+ ? `step-hook step-hook-${step.hookType}`
1197
+ : "";
1198
+ const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
487
1199
 
488
1200
  return `
489
- <div class="step-item" style="padding-left: ${depth * 20}px">
490
- <div class="step-header ${stepClass}" onclick="toggleStepDetails(this)">
491
- <span class="step-icon">${getStatusIcon(step.status)}</span>
492
- <span class="step-title">${sanitizeHTML(
493
- step.title
494
- )}${hookIndicator}</span>
495
- <span class="step-duration">${formatDuration(
496
- step.duration
497
- )}</span>
498
- </div>
499
- <div class="step-details">
500
- ${
501
- step.codeLocation
502
- ? `<div><strong>Location:</strong> ${sanitizeHTML(
503
- step.codeLocation
504
- )}</div>`
505
- : ""
506
- }
507
- ${
508
- step.errorMessage
509
- ? `
510
- <div class="step-error">
511
- <strong>Error:</strong> ${sanitizeHTML(step.errorMessage)}
512
- ${
513
- step.stackTrace
514
- ? `<pre>${sanitizeHTML(step.stackTrace)}</pre>`
515
- : ""
516
- }
517
- </div>
518
- `
519
- : ""
520
- }
521
- ${
522
- hasNestedSteps
523
- ? `
524
- <div class="nested-steps">
525
- ${generateStepsHTML(step.steps, depth + 1)}
526
- </div>
527
- `
528
- : ""
529
- }
530
- </div>
531
- </div>
532
- `;
533
- })
534
- .join("");
535
- };
536
-
537
- return `
538
- <div class="test-suite" data-status="${
539
- test.status
540
- }" data-browser="${browser}" data-tags="${(test.tags || []).join(",")}">
541
- <div class="suite-header" onclick="toggleTestDetails(this)">
542
- <div>
543
- <span class="${getStatusClass(
544
- test.status
545
- )}">${test.status.toUpperCase()}</span>
546
- <span class="test-name">${sanitizeHTML(testName)}</span>
547
- <span class="test-browser">(${browser})</span>
548
- </div>
549
- <div class="test-meta">
550
- <span class="test-duration">${formatDuration(
551
- test.duration
1201
+ <div class="step-item" style="--depth: ${depth};">
1202
+ <div class="step-header ${stepClass}" role="button" aria-expanded="false">
1203
+ <span class="step-icon">${getStatusIcon(step.status)}</span>
1204
+ <span class="step-title">${sanitizeHTML(
1205
+ step.title
1206
+ )}${hookIndicator}</span>
1207
+ <span class="step-duration">${formatDuration(
1208
+ step.duration
552
1209
  )}</span>
553
1210
  </div>
554
- </div>
555
- <div class="suite-content">
556
- <div class="test-details">
557
- <h3>Test Details</h3>
558
- <p><strong>Status:</strong> <span class="${getStatusClass(
559
- test.status
560
- )}">${test.status.toUpperCase()}</span></p>
561
- <p><strong>Browser:</strong> ${browser}</p>
562
- <p><strong>Duration:</strong> ${formatDuration(test.duration)}</p>
1211
+ <div class="step-details" style="display: none;">
563
1212
  ${
564
- test.tags && test.tags.length > 0
565
- ? `<p><strong>Tags:</strong> ${test.tags
566
- .map((t) => `<span class="tag">${t}</span>`)
567
- .join(" ")}</p>`
1213
+ step.codeLocation
1214
+ ? `<div class="step-info"><strong>Location:</strong> ${sanitizeHTML(
1215
+ step.codeLocation
1216
+ )}</div>`
568
1217
  : ""
569
1218
  }
570
-
571
- <h3>Test Steps</h3>
572
- <div class="steps-list">
573
- ${generateStepsHTML(test.steps)}
574
- </div>
575
-
576
1219
  ${
577
- test.screenshots && test.screenshots.length > 0
1220
+ step.errorMessage
578
1221
  ? `
579
- <div class="attachments-section">
580
- <h4>Screenshots</h4>
581
- <div class="attachments-grid">
582
- ${test.screenshots
583
- .map(
584
- (screenshot) => `
585
- <div class="attachment-item">
586
- <img src="${screenshot}" alt="Screenshot">
587
- <div class="attachment-info">
588
- <a href="${screenshot}" target="_blank">View Full Size</a>
589
- </div>
590
- </div>
591
- `
592
- )
593
- .join("")}
594
- </div>
595
- </div>
596
- `
1222
+ <div class="step-error">
1223
+ <strong>Error:</strong> ${sanitizeHTML(step.errorMessage)}
1224
+ ${
1225
+ step.stackTrace
1226
+ ? `<pre class="stack-trace">${sanitizeHTML(
1227
+ step.stackTrace
1228
+ )}</pre>`
1229
+ : ""
1230
+ }
1231
+ </div>`
597
1232
  : ""
598
1233
  }
599
-
600
1234
  ${
601
- test.codeSnippet
602
- ? `
603
- <div class="code-section">
604
- <h4>Code Snippet</h4>
605
- <pre>${sanitizeHTML(test.codeSnippet)}</pre>
606
- </div>
607
- `
1235
+ hasNestedSteps
1236
+ ? `<div class="nested-steps">${generateStepsHTML(
1237
+ step.steps,
1238
+ depth + 1
1239
+ )}</div>`
608
1240
  : ""
609
1241
  }
610
1242
  </div>
1243
+ </div>`;
1244
+ })
1245
+ .join("");
1246
+ };
1247
+
1248
+ return `
1249
+ <div class="test-case" data-status="${
1250
+ test.status
1251
+ }" data-browser="${sanitizeHTML(browser)}" data-tags="${(test.tags || [])
1252
+ .join(",")
1253
+ .toLowerCase()}">
1254
+ <div class="test-case-header" role="button" aria-expanded="false">
1255
+ <div class="test-case-summary">
1256
+ <span class="status-badge ${getStatusClass(test.status)}">${String(
1257
+ test.status
1258
+ ).toUpperCase()}</span>
1259
+ <span class="test-case-title" title="${sanitizeHTML(
1260
+ test.name
1261
+ )}">${sanitizeHTML(testTitle)}</span>
1262
+ <span class="test-case-browser">(${sanitizeHTML(browser)})</span>
1263
+ </div>
1264
+ <div class="test-case-meta">
1265
+ ${
1266
+ test.tags && test.tags.length > 0
1267
+ ? test.tags
1268
+ .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1269
+ .join(" ")
1270
+ : ""
1271
+ }
1272
+ <span class="test-duration">${formatDuration(test.duration)}</span>
611
1273
  </div>
612
1274
  </div>
613
- `;
1275
+ <div class="test-case-content" style="display: none;">
1276
+ <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
1277
+ ${
1278
+ test.error // This is for the overall test error, not step error
1279
+ ? `<div class="test-error-summary"><h4>Test Error:</h4><pre>${sanitizeHTML(
1280
+ test.error // Assuming test.error is the message; if it has a stack, that's separate
1281
+ )}</pre></div>`
1282
+ : ""
1283
+ }
1284
+
1285
+ <h4>Steps</h4>
1286
+ <div class="steps-list">${generateStepsHTML(test.steps)}</div>
1287
+
1288
+ ${/* NEW: stdout and stderr sections START */ ""}
1289
+ ${
1290
+ test.stdout && test.stdout.length > 0
1291
+ ? `
1292
+ <div class="console-output-section">
1293
+ <h4>Console Output (stdout)</h4>
1294
+ <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
1295
+ .map((line) => sanitizeHTML(line))
1296
+ .join("\n")}</pre>
1297
+ </div>`
1298
+ : ""
1299
+ }
1300
+ ${
1301
+ test.stderr && test.stderr.length > 0
1302
+ ? `
1303
+ <div class="console-output-section">
1304
+ <h4>Console Output (stderr)</h4>
1305
+ <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
1306
+ .map((line) => sanitizeHTML(line))
1307
+ .join("\n")}</pre>
1308
+ </div>`
1309
+ : ""
1310
+ }
1311
+ ${/* NEW: stdout and stderr sections END */ ""}
1312
+
1313
+ ${
1314
+ test.screenshots && test.screenshots.length > 0
1315
+ ? `
1316
+ <div class="attachments-section">
1317
+ <h4>Screenshots</h4>
1318
+ <div class="attachments-grid">
1319
+ ${test.screenshots
1320
+ .map((screenshot) => {
1321
+ // Ensure screenshot.path and screenshot.name are accessed correctly
1322
+ const imgSrc = sanitizeHTML(screenshot.path || "");
1323
+ const screenshotName = sanitizeHTML(
1324
+ screenshot.name || "Screenshot"
1325
+ );
1326
+ return imgSrc
1327
+ ? `
1328
+ <div class="attachment-item screenshot-item">
1329
+ <a href="${imgSrc}" target="_blank" title="Click to view ${screenshotName} (full size)">
1330
+ <img src="${imgSrc}" alt="${screenshotName}" loading="lazy">
1331
+ </a>
1332
+ <div class="attachment-caption">${screenshotName}</div>
1333
+ </div>`
1334
+ : "";
1335
+ })
1336
+ .join("")}
1337
+ </div>
1338
+ </div>`
1339
+ : ""
1340
+ }
1341
+
1342
+ ${
1343
+ test.videos && test.videos.length > 0
1344
+ ? `
1345
+ <div class="attachments-section">
1346
+ <h4>Videos</h4>
1347
+ ${test.videos
1348
+ .map(
1349
+ (video) => `
1350
+ <div class="video-item">
1351
+ <a href="${sanitizeHTML(
1352
+ video.path
1353
+ )}" target="_blank">View Video: ${sanitizeHTML(
1354
+ video.name || path.basename(video.path) // path.basename might not be available if path module not passed/scoped
1355
+ )}</a>
1356
+ </div>`
1357
+ )
1358
+ .join("")}
1359
+ </div>`
1360
+ : ""
1361
+ }
1362
+
1363
+ ${
1364
+ test.traces && test.traces.length > 0
1365
+ ? `
1366
+ <div class="attachments-section">
1367
+ <h4>Traces</h4>
1368
+ ${test.traces
1369
+ .map(
1370
+ (trace) => `
1371
+ <div class="trace-item">
1372
+ <a href="${sanitizeHTML(
1373
+ trace.path
1374
+ )}" target="_blank" download>Download Trace: ${sanitizeHTML(
1375
+ trace.name || path.basename(trace.path) // path.basename might not be available if path module not passed/scoped
1376
+ )}</a>
1377
+ (Open with Playwright Trace Viewer)
1378
+ </div>`
1379
+ )
1380
+ .join("")}
1381
+ </div>`
1382
+ : ""
1383
+ }
1384
+
1385
+ ${
1386
+ test.codeSnippet
1387
+ ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
1388
+ test.codeSnippet
1389
+ )}</code></pre></div>`
1390
+ : ""
1391
+ }
1392
+ </div>
1393
+ </div>`;
614
1394
  })
615
1395
  .join("");
616
- };
1396
+ }
617
1397
 
618
- // Generate HTML with optimized CSS and JS
619
1398
  return `
620
1399
  <!DOCTYPE html>
621
1400
  <html lang="en">
622
1401
  <head>
623
1402
  <meta charset="UTF-8">
624
1403
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1404
+ <link rel="icon" type="image/png" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1405
+ <link rel="apple-touch-icon" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
625
1406
  <title>Playwright Pulse Report</title>
626
1407
  <style>
627
- /* Base Styles */
628
1408
  :root {
629
- --primary-color: #3f51b5;
630
- --secondary-color: #ff4081;
631
- --success-color: #4CAF50;
632
- --danger-color: #F44336;
633
- --warning-color: #FFC107;
634
- --info-color: #2196F3;
635
- --light-color: #f5f5f5;
636
- --dark-color: #212121;
637
- --text-color: #424242;
638
- --border-color: #e0e0e0;
1409
+ --primary-color: #3f51b5; /* Indigo */
1410
+ --secondary-color: #ff4081; /* Pink */
1411
+ --accent-color: #673ab7; /* Deep Purple */
1412
+ --accent-color-alt: #FF9800; /* Orange for duration charts */
1413
+ --success-color: #4CAF50; /* Green */
1414
+ --danger-color: #F44336; /* Red */
1415
+ --warning-color: #FFC107; /* Amber */
1416
+ --info-color: #2196F3; /* Blue */
1417
+ --light-gray-color: #f5f5f5;
1418
+ --medium-gray-color: #e0e0e0;
1419
+ --dark-gray-color: #757575;
1420
+ --text-color: #333;
1421
+ --text-color-secondary: #555;
1422
+ --border-color: #ddd;
1423
+ --background-color: #f8f9fa; /* Even lighter gray */
1424
+ --card-background-color: #fff;
1425
+ --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
1426
+ --border-radius: 8px;
1427
+ --box-shadow: 0 5px 15px rgba(0,0,0,0.08); /* Softer shadow */
1428
+ --box-shadow-light: 0 3px 8px rgba(0,0,0,0.05);
1429
+ --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.07);
639
1430
  }
640
1431
 
641
1432
  body {
642
- font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif;
1433
+ font-family: var(--font-family);
643
1434
  margin: 0;
644
- padding: 0;
645
- background-color: #fafafa;
1435
+ background-color: var(--background-color);
646
1436
  color: var(--text-color);
647
- line-height: 1.6;
1437
+ line-height: 1.65; /* Increased line height */
1438
+ font-size: 16px;
648
1439
  }
649
1440
 
650
1441
  .container {
651
- margin: 20px auto;
652
- padding: 20px;
653
- background-color: #fff;
654
- border-radius: 8px;
655
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1442
+ max-width: 1600px;
1443
+ padding: 30px; /* Increased padding */
1444
+ border-radius: var(--border-radius);
1445
+ box-shadow: var(--box-shadow);
1446
+ background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec);
656
1447
  }
657
1448
 
658
- /* Header Styles */
659
1449
  .header {
660
1450
  display: flex;
661
1451
  justify-content: space-between;
662
1452
  align-items: center;
663
1453
  flex-wrap: wrap;
664
- margin-bottom: 20px;
665
- padding-bottom: 20px;
1454
+ padding-bottom: 25px;
666
1455
  border-bottom: 1px solid var(--border-color);
1456
+ margin-bottom: 25px;
667
1457
  }
1458
+ .header-title { display: flex; align-items: center; gap: 15px; }
1459
+ .header h1 { margin: 0; font-size: 1.85em; font-weight: 600; color: var(--primary-color); }
1460
+ #report-logo { height: 40px; width: 40px; border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);}
1461
+ .run-info { font-size: 0.9em; text-align: right; color: var(--text-color-secondary); line-height:1.5;}
1462
+ .run-info strong { color: var(--text-color); }
668
1463
 
669
- .header h1 {
670
- margin: 0;
671
- font-size: 24px;
672
- color: var(--primary-color);
673
- display: flex;
674
- align-items: center;
675
- gap: 10px;
676
- }
677
-
678
- .run-info {
679
- background: #f5f5f5;
680
- padding: 10px 15px;
681
- border-radius: 6px;
682
- font-size: 14px;
683
- }
684
-
685
- /* Tab Styles */
686
- .tabs {
687
- display: flex;
688
- border-bottom: 1px solid var(--border-color);
689
- margin-bottom: 20px;
690
- }
691
-
1464
+ .tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 30px; overflow-x: auto; }
692
1465
  .tab-button {
693
- padding: 10px 20px;
694
- background: none;
695
- border: none;
696
- cursor: pointer;
697
- font-size: 16px;
698
- color: #666;
699
- position: relative;
1466
+ padding: 15px 25px; background: none; border: none; border-bottom: 3px solid transparent;
1467
+ cursor: pointer; font-size: 1.1em; font-weight: 600; color: black;
1468
+ transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap;
700
1469
  }
701
-
702
- .tab-button.active {
703
- color: var(--primary-color);
704
- font-weight: 500;
705
- }
706
-
707
- .tab-button.active::after {
708
- content: '';
709
- position: absolute;
710
- bottom: -1px;
711
- left: 0;
712
- right: 0;
713
- height: 2px;
714
- background: var(--primary-color);
715
- }
716
-
717
- .tab-content {
718
- display: none;
719
- }
720
-
721
- .tab-content.active {
722
- display: block;
723
- }
724
-
725
- /* Main dashboard grid layout */
726
- .dashboard-grid {
1470
+ .tab-button:hover { color: var(--accent-color); }
1471
+ .tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
1472
+ .tab-content { display: none; animation: fadeIn 0.4s ease-out; }
1473
+ .tab-content.active { display: block; }
1474
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
1475
+
1476
+ .dashboard-grid {
727
1477
  display: grid;
728
- grid-template-columns: 1fr;
729
- gap: 20px;
730
- padding: 16px 0;
1478
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
1479
+ gap: 22px; margin-bottom: 35px;
731
1480
  }
732
-
733
1481
  .summary-card {
734
- background: linear-gradient(to bottom right, #f0f4ff, #ffffff);
735
- border-radius: 8px;
736
- padding: 20px;
737
- box-shadow: 0 2px 5px rgba(0,0,0,0.05);
738
- text-align: center;
739
- transition: transform 0.2s ease, box-shadow 0.2s ease;
740
- cursor: pointer;
741
- }
742
-
743
- .summary-card:hover {
744
- transform: translateY(-4px);
745
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
746
- background: #90e0e3; /* optional light background change */
747
- }
748
-
749
- .summary-card h3 {
750
- margin: 0 0 10px;
751
- font-size: 16px;
752
- color: #666;
753
- }
1482
+ background-color: var(--card-background-color); border: 1px solid var(--border-color);
1483
+ border-radius: var(--border-radius); padding: 22px; text-align: center;
1484
+ box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease;
1485
+ }
1486
+ .summary-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow); }
1487
+ .summary-card h3 { margin: 0 0 10px; font-size: 1.05em; font-weight: 500; color: var(--text-color-secondary); }
1488
+ .summary-card .value { font-size: 2.4em; font-weight: 600; margin-bottom: 8px; }
1489
+ .summary-card .trend-percentage { font-size: 1em; color: var(--dark-gray-color); }
1490
+ .status-passed .value, .stat-passed svg { color: var(--success-color); }
1491
+ .status-failed .value, .stat-failed svg { color: var(--danger-color); }
1492
+ .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
754
1493
 
755
- .summary-card .value {
756
- font-size: 28px;
757
- font-weight: 600;
758
- margin: 10px 0;
1494
+ .dashboard-bottom-row {
1495
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); /* Increased minmax */
1496
+ gap: 28px; align-items: stretch; /* Stretch for same height cards */
759
1497
  }
760
-
761
- .status-passed .value {
762
- color: var(--success-color);
763
- }
764
-
765
- .status-failed .value {
766
- color: var(--danger-color);
1498
+ .pie-chart-wrapper, .suites-widget, .trend-chart {
1499
+ background-color: var(--card-background-color); padding: 28px; /* Increased padding */
1500
+ border-radius: var(--border-radius); box-shadow: var(--box-shadow-light);
1501
+ display: flex; flex-direction: column; /* For internal alignment */
767
1502
  }
768
-
769
- .status-skipped .value {
770
- color: var(--warning-color);
1503
+ .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3, .main-chart-title {
1504
+ text-align: center; margin-top: 0; margin-bottom: 25px;
1505
+ font-size: 1.25em; font-weight: 600; color: var(--text-color);
771
1506
  }
1507
+ .pie-chart-wrapper svg, .trend-chart-container svg { display: block; margin: 0 auto; max-width: 100%; height: auto; flex-grow: 1;}
772
1508
 
773
- .pie-chart-container {
774
- grid-column: span 2;
775
- background: linear-gradient(to bottom right, #f0f4ff, #ffffff);
776
- border-radius: 8px;
777
- padding: 20px;
778
- box-shadow: 0 2px 5px rgba(0,0,0,0.05);
779
- }
1509
+ .chart-tooltip {
1510
+ position: absolute; padding: 10px 15px; background: rgba(10,10,10,0.92); color: #f5f5f5; /* Slightly lighter text on dark */
1511
+ border: none; border-radius: 6px; pointer-events: none;
1512
+ font-size: 13px; line-height: 1.5; white-space: nowrap; z-index: 10000;
1513
+ box-shadow: 0 4px 12px rgba(0,0,0,0.35); opacity: 0; transition: opacity 0.15s ease-in-out;
1514
+ }
1515
+ .chart-tooltip strong { color: #fff; font-weight: 600;}
1516
+ .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
1517
+ .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
1518
+ .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
1519
+ .status-badge-small-tooltip.status-skipped { background-color: var(--warning-color); }
1520
+ .status-badge-small-tooltip.status-unknown { background-color: var(--dark-gray-color); }
780
1521
 
781
- /* Test Run Summary Styles */
1522
+ .suites-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
1523
+ .summary-badge { background-color: var(--light-gray-color); color: var(--text-color-secondary); padding: 7px 14px; border-radius: 16px; font-size: 0.9em; }
1524
+ .suites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
1525
+ .suite-card {
1526
+ border: 1px solid var(--border-color); border-left-width: 5px;
1527
+ border-radius: calc(var(--border-radius) / 1.5); padding: 20px;
1528
+ background-color: var(--card-background-color); transition: box-shadow 0.2s ease, border-left-color 0.2s ease;
1529
+ }
1530
+ .suite-card:hover { box-shadow: var(--box-shadow); }
1531
+ .suite-card.status-passed { border-left-color: var(--success-color); }
1532
+ .suite-card.status-failed { border-left-color: var(--danger-color); }
1533
+ .suite-card.status-skipped { border-left-color: var(--warning-color); }
1534
+ .suite-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
1535
+ .suite-name { font-weight: 600; font-size: 1.05em; color: var(--text-color); margin-right: 10px; word-break: break-word;}
1536
+ .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;}
1537
+ .suite-card-body .test-count { font-size: 0.95em; color: var(--text-color-secondary); display: block; margin-bottom: 10px; }
1538
+ .suite-stats { display: flex; gap: 14px; font-size: 0.95em; align-items: center; }
1539
+ .suite-stats span { display: flex; align-items: center; gap: 6px; }
1540
+ .suite-stats svg { vertical-align: middle; font-size: 1.15em; }
1541
+
782
1542
  .filters {
783
- display: flex;
784
- gap: 10px;
785
- margin-bottom: 20px;
786
- flex-wrap: wrap;
787
- padding: 0.75rem;
788
- }
789
-
790
- .filters input,
791
- .filters select {
792
- padding: 8px 12px;
793
- border: 1px solid #ddd;
794
- border-radius: 4px;
795
- font-size: 14px;
796
- }
797
-
798
- .filters button {
799
- padding: 8px 16px;
800
- background: var(--primary-color);
801
- color: white;
802
- border: none;
803
- border-radius: 4px;
804
- cursor: pointer;
805
- }
806
-
807
- .test-suite {
808
- margin-bottom: 15px;
809
- border: 1px solid #eee;
810
- border-radius: 6px;
811
- overflow: hidden;
812
- }
813
-
814
- .suite-header {
815
- padding: 12px 15px;
816
- background: #f9f9f9;
817
- cursor: pointer;
818
- display: flex;
819
- justify-content: space-between;
820
- align-items: center;
821
- }
822
-
823
- .suite-header:hover {
824
- background: #f0f0f0;
825
- }
1543
+ display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 28px;
1544
+ padding: 20px; background-color: var(--light-gray-color); border-radius: var(--border-radius);
1545
+ box-shadow: var(--box-shadow-inset); border-color: black; border-style: groove;
1546
+ }
1547
+ .filters input, .filters select, .filters button {
1548
+ padding: 11px 15px; border: 1px solid var(--border-color);
1549
+ border-radius: 6px; font-size: 1em;
1550
+ }
1551
+ .filters input { flex-grow: 1; min-width: 240px;}
1552
+ .filters select {min-width: 180px;}
1553
+ .filters button { background-color: var(--primary-color); color: white; cursor: pointer; transition: background-color 0.2s ease, box-shadow 0.2s ease; border: none; }
1554
+ .filters button:hover { background-color: var(--accent-color); box-shadow: 0 2px 5px rgba(0,0,0,0.15);}
1555
+
1556
+ .test-case {
1557
+ margin-bottom: 15px; border: 1px solid var(--border-color);
1558
+ border-radius: var(--border-radius); background-color: var(--card-background-color);
1559
+ box-shadow: var(--box-shadow-light); overflow: hidden;
1560
+ }
1561
+ .test-case-header {
1562
+ padding: 10px 15px; background-color: #fff; cursor: pointer;
1563
+ display: flex; justify-content: space-between; align-items: center;
1564
+ border-bottom: 1px solid transparent;
1565
+ transition: background-color 0.2s ease;
1566
+ }
1567
+ .test-case-header:hover { background-color: #f4f6f8; } /* Lighter hover */
1568
+ .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: #f9fafb; }
826
1569
 
827
- .suite-content {
828
- display: none;
829
- padding: 15px;
830
- background: white;
831
- }
832
-
833
- .test-details h3 {
834
- margin-top: 0;
835
- font-size: 18px;
836
- color: var(--dark-color);
837
- }
1570
+ .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
1571
+ .test-case-title { font-weight: 600; color: var(--text-color); font-size: 1em; }
1572
+ .test-case-browser { font-size: 0.9em; color: var(--text-color-secondary); }
1573
+ .test-case-meta { display: flex; align-items: center; gap: 12px; font-size: 0.9em; color: var(--text-color-secondary); flex-shrink: 0; }
1574
+ .test-duration { background-color: var(--light-gray-color); padding: 4px 10px; border-radius: 12px; font-size: 0.9em;}
838
1575
 
839
- .steps-list {
840
- margin: 15px 0;
841
- padding: 0;
842
- list-style: none;
1576
+ .status-badge {
1577
+ padding: 5px; border-radius: 6px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase;
1578
+ min-width: 70px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
843
1579
  }
1580
+ .status-badge.status-passed { background-color: var(--success-color); }
1581
+ .status-badge.status-failed { background-color: var(--danger-color); }
1582
+ .status-badge.status-skipped { background-color: var(--warning-color); }
1583
+ .status-badge.status-unknown { background-color: var(--dark-gray-color); }
844
1584
 
845
- .step-item {
846
- margin-bottom: 8px;
847
- }
1585
+ .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; }
848
1586
 
1587
+ .test-case-content { display: none; padding: 20px; border-top: 1px solid var(--border-color); background-color: #fcfdff; }
1588
+ .test-case-content h4 { margin-top: 22px; margin-bottom: 14px; font-size: 1.15em; color: var(--primary-color); }
1589
+ .test-case-content p { margin-bottom: 10px; font-size: 1em; }
1590
+ .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; }
1591
+ .test-error-summary h4 { color: var(--danger-color); margin-top:0;}
1592
+ .test-error-summary pre { white-space: pre-wrap; word-break: break-all; color: var(--danger-color); font-size: 0.95em;}
1593
+
1594
+ .steps-list { margin: 18px 0; }
1595
+ .step-item { margin-bottom: 8px; padding-left: calc(var(--depth, 0) * 28px); }
849
1596
  .step-header {
850
- display: flex;
851
- align-items: center;
852
- cursor: pointer;
853
- padding: 8px;
854
- border-radius: 4px;
855
- }
856
-
857
- .step-header:hover {
858
- background: #f5f5f5;
859
- }
860
-
861
- .step-icon {
862
- margin-right: 8px;
863
- width: 20px;
864
- text-align: center;
865
- }
866
-
867
- .step-title {
868
- flex: 1;
869
- }
870
-
871
- .step-duration {
872
- color: #666;
873
- font-size: 12px;
874
- }
875
-
876
- .step-details {
877
- display: none;
878
- padding: 10px;
879
- margin-top: 5px;
880
- background: #f9f9f9;
881
- border-radius: 4px;
882
- font-size: 14px;
883
- }
884
-
885
- .step-error {
886
- color: var(--danger-color);
887
- margin-top: 8px;
888
- padding: 8px;
889
- background: rgba(244, 67, 54, 0.1);
890
- border-radius: 4px;
891
- font-size: 13px;
892
- }
893
-
894
- .step-hook {
895
- background: rgba(33, 150, 243, 0.1);
896
- }
897
-
898
- .nested-steps {
899
- display: none;
900
- padding-left: 20px;
901
- border-left: 2px solid #eee;
902
- margin-top: 8px;
903
- }
904
-
905
- .attachments-grid {
906
- display: grid;
907
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
908
- gap: 15px;
909
- margin-top: 15px;
910
- }
911
-
1597
+ display: flex; align-items: center; cursor: pointer;
1598
+ padding: 10px 14px; border-radius: 6px; background-color: #fff;
1599
+ border: 1px solid var(--light-gray-color);
1600
+ transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
1601
+ }
1602
+ .step-header:hover { background-color: #f0f2f5; border-color: var(--medium-gray-color); box-shadow: var(--box-shadow-inset); }
1603
+ .step-icon { margin-right: 12px; width: 20px; text-align: center; font-size: 1.1em; }
1604
+ .step-title { flex: 1; font-size: 1em; }
1605
+ .step-duration { color: var(--dark-gray-color); font-size: 0.9em; }
1606
+ .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); }
1607
+ .step-info { margin-bottom: 8px; }
1608
+ .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); }
1609
+ .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; }
1610
+ .step-hook { background-color: rgba(33,150,243,0.04); border-left: 3px solid var(--info-color) !important; }
1611
+ .step-hook .step-title { font-style: italic; color: var(--info-color)}
1612
+ .nested-steps { margin-top: 12px; }
1613
+
1614
+ .attachments-section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--light-gray-color); }
1615
+ .attachments-section h4 { margin-top: 0; margin-bottom: 20px; font-size: 1.1em; color: var(--text-color); }
1616
+ .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
912
1617
  .attachment-item {
913
- border: 1px solid #eee;
914
- border-radius: 4px;
915
- overflow: hidden;
1618
+ border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: #fff;
1619
+ box-shadow: var(--box-shadow-light); overflow: hidden; display: flex; flex-direction: column;
1620
+ transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
916
1621
  }
917
-
1622
+ .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
918
1623
  .attachment-item img {
919
- width: 100%;
920
- height: auto;
921
- display: block;
922
- }
1624
+ width: 100%; height: 180px; object-fit: cover; display: block;
1625
+ border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease;
1626
+ }
1627
+ .attachment-item a:hover img { opacity: 0.85; }
1628
+ .attachment-caption {
1629
+ padding: 12px 15px; font-size: 0.9em; text-align: center;
1630
+ color: var(--text-color-secondary); word-break: break-word; background-color: var(--light-gray-color);
1631
+ }
1632
+ .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
1633
+ .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
1634
+ .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;}
1635
+
1636
+ .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
1637
+ .trend-chart-container svg .chart-axis path, .trend-chart-container svg .chart-axis line { stroke: var(--border-color); shape-rendering: crispEdges;}
1638
+ .trend-chart-container svg .chart-axis text { fill: var(--text-color-secondary); font-size: 12px; }
1639
+ .trend-chart-container svg .main-chart-title { font-size: 1.1em; font-weight: 600; fill: var(--text-color); }
1640
+ .chart-line { fill: none; stroke-width: 2.5px; }
1641
+ .chart-line.total-line { stroke: var(--primary-color); }
1642
+ .chart-line.passed-line { stroke: var(--success-color); }
1643
+ .chart-line.failed-line { stroke: var(--danger-color); }
1644
+ .chart-line.skipped-line { stroke: var(--warning-color); }
1645
+ .chart-line.duration-line { stroke: var(--accent-color-alt); }
1646
+ .chart-line.history-duration-line { stroke: var(--accent-color); stroke-width: 2px;}
923
1647
 
924
- .tag {
925
- display: inline-block;
926
- background: #e0e0e0;
927
- padding: 2px 6px;
928
- border-radius: 4px;
929
- font-size: 12px;
930
- margin-right: 5px;
931
- }
932
- .status-badge {
933
- padding: 3px 8px;
934
- border-radius: 4px;
935
- font-size: 12px;
936
- font-weight: bold;
937
- color: white;
938
- text-transform: uppercase;
939
- }
940
-
941
- span.status-passed {
942
- background-color: #4CAF50 !important; /* Bright green */
943
- color: white;
944
- border-radius: 4px;
945
- padding: 4px;
946
- font-size: 0.75rem;
947
- }
948
-
949
- span.status-failed {
950
- background-color: #F44336 !important; /* Bright red */
951
- color: white;
952
- border-radius: 4px;
953
- padding: 4px;
954
- font-size: 0.75rem;
955
- }
956
-
957
- span.status-skipped {
958
- background-color: #FFC107 !important; /* Deep yellow */
959
- color: white;
960
- border-radius: 4px;
961
- padding: 4px;
962
- font-size: 0.75rem;
963
- }
964
-
965
- /* Enhanced Pie Chart Styles */
966
- .pie-chart-container {
967
- display: flex;
968
- flex-direction: column;
969
- align-items: center;
970
- margin: 20px 0;
971
- }
972
-
973
- .pie-chart-svg {
974
- margin: 0 auto;
975
- }
976
-
977
- .pie-chart-total {
978
- font-size: 18px;
979
- font-weight: bold;
980
- fill: #333;
981
- }
982
-
983
- .pie-chart-label {
984
- font-size: 12px;
985
- fill: #666;
986
- }
987
-
988
- .pie-chart-legend {
989
- display: flex;
990
- flex-wrap: wrap;
991
- justify-content: center;
992
- gap: 15px;
993
- margin-top: 15px;
994
- }
995
-
996
- .legend-item {
997
- display: flex;
998
- align-items: center;
999
- gap: 5px;
1000
- font-size: 14px;
1001
- }
1002
-
1003
- .legend-color {
1004
- width: 12px;
1005
- height: 12px;
1006
- border-radius: 50%;
1007
- display: inline-block;
1008
- }
1009
-
1010
- .legend-value {
1011
- font-weight: 500;
1012
- }
1013
- .test-name {
1014
- font-weight: 600;
1015
- }
1016
-
1017
- /* Below summary cards: chart and test suites */
1018
- .dashboard-bottom {
1019
- display: flex;
1020
- flex-direction: column;
1021
- gap: 24px;
1022
- }
1023
- /* Responsive Styles */
1024
- /* Mobile (up to 480px) and Tablet (481px to 768px) Responsive Styles */
1025
-
1026
- @media (min-width: 768px) {
1027
- .dashboard-grid {
1028
- grid-template-columns: repeat(4, 1fr); /* Four summary cards side-by-side */
1029
- }
1030
- .dashboard-bottom {
1031
- flex-direction: row;
1032
- }
1033
- .test-distribution {
1034
- flex: 1;
1035
- }
1036
- .test-suites {
1037
- flex: 2; /* dynamically expand */
1038
- min-width: 300px;
1039
- padding: 0.75rem;
1040
- }
1041
- }
1042
- @media (max-width: 768px) {
1043
- /* Base container adjustments */
1044
- .container {
1045
- padding: 15px;
1046
- margin: 10px auto;
1047
- }
1048
-
1049
- /* Header adjustments */
1050
- .header {
1051
- flex-direction: column;
1052
- align-items: flex-start;
1053
- gap: 15px;
1054
- padding-bottom: 15px;
1055
- }
1056
-
1057
- .run-info {
1058
- font-size: 13px;
1059
- }
1060
-
1061
- /* Tab adjustments */
1062
- .tabs {
1063
- overflow-x: auto;
1064
- white-space: nowrap;
1065
- padding-bottom: 5px;
1066
- }
1067
-
1068
- .tab-button {
1069
- padding: 8px 15px;
1070
- font-size: 14px;
1071
- }
1072
-
1073
- /* Dashboard Grid adjustments */
1074
- .dashboard-grid {
1075
- grid-template-columns: 1fr;
1076
- gap: 15px;
1077
- }
1078
-
1079
- .summary-card {
1080
- padding: 15px;
1081
- }
1082
-
1083
- .summary-card .value {
1084
- font-size: 24px;
1085
- }
1086
-
1087
- .pie-chart-container {
1088
- padding: 15px;
1089
- }
1090
-
1091
- .pie-chart-container svg {
1092
- width: 300px;
1093
- height: 300px;
1094
- }
1095
-
1096
- /* Test Suites Widget adjustments */
1097
- .suites-widget {
1098
- padding: 8px;
1099
- }
1100
-
1101
- .suites-header {
1102
- flex-direction: column;
1103
- align-items: flex-start;
1104
- gap: 10px;
1105
- }
1106
-
1107
- .suites-grid {
1108
- grid-template-columns: 1fr;
1109
- }
1110
-
1111
- /* Test Run Summary adjustments */
1112
- .filters {
1113
- flex-direction: column;
1114
- gap: 8px;
1115
- padding: 0.75rem;
1116
- }
1117
-
1118
- .filters input,
1119
- .filters select {
1120
- width: 100%;
1121
- padding: 8px;
1122
- }
1123
-
1124
- .filters button {
1125
- width: 100%;
1126
- margin-top: 5px;
1127
- }
1128
-
1129
- .test-suite {
1130
- margin-bottom: 10px;
1131
- }
1132
-
1133
- .suite-header {
1134
- padding: 10px;
1135
- flex-wrap: wrap;
1136
- }
1137
-
1138
- .test-name {
1139
- display: block;
1140
- width: 100%;
1141
- margin-top: 5px;
1142
- font-weight: 600;
1143
- }
1144
-
1145
- .test-meta {
1146
- margin-top: 5px;
1147
- }
1148
-
1149
- .suite-content {
1150
- padding: 10px;
1151
- }
1152
-
1153
- .steps-list {
1154
- margin: 10px 0;
1155
- }
1156
-
1157
- .step-header {
1158
- padding: 6px;
1159
- }
1160
-
1161
- .step-icon {
1162
- font-size: 14px;
1163
- }
1164
-
1165
- .step-title {
1166
- font-size: 14px;
1167
- }
1168
-
1169
- .step-duration {
1170
- font-size: 11px;
1171
- }
1172
-
1173
- .attachments-grid {
1174
- grid-template-columns: repeat(2, 1fr);
1175
- }
1176
-
1177
- /* Specific adjustments for mobile only (up to 480px) */
1178
- @media (max-width: 480px) {
1179
- .header h1 {
1180
- font-size: 20px;
1181
- }
1182
-
1183
- .summary-card .value {
1184
- font-size: 22px;
1185
- }
1186
- .pie-chart-container {
1187
- grid-column: span 1;
1188
- }
1189
- .pie-chart-container svg {
1190
- width: 300px;
1191
- height: 300px;
1192
- }
1193
-
1194
- .attachments-grid {
1195
- grid-template-columns: 1fr;
1196
- }
1197
-
1198
- .step-item {
1199
- padding-left: 0 !important;
1200
- }
1201
-
1202
- .nested-steps {
1203
- padding-left: 10px;
1648
+ .pie-center-percentage { font-size: calc(var(--outer-radius, 100px) / 3.5); font-weight: bold; fill: var(--primary-color); } /* Use CSS var if possible */
1649
+ .pie-center-label { font-size: calc(var(--outer-radius, 100px) / 7); fill: var(--text-color-secondary); }
1650
+ .pie-chart-legend-d3 text, .chart-legend-d3 text { fill: var(--text-color); font-size: 12px;}
1651
+ .chart-legend-bottom {font-size: 12px;}
1652
+
1653
+
1654
+ .test-history-container h2 { font-size: 1.6em; margin-bottom: 18px; color: var(--primary-color); border-bottom: 1px solid var(--border-color); padding-bottom: 12px;}
1655
+ .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
1656
+ .test-history-card {
1657
+ background: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius);
1658
+ padding: 22px; box-shadow: var(--box-shadow-light); display: flex; flex-direction: column;
1659
+ }
1660
+ .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); }
1661
+ .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1662
+ .test-history-header p { font-weight: 500 }
1663
+ .test-history-trend { margin-bottom: 20px; min-height: 110px; }
1664
+ .test-history-trend svg { display: block; margin: 0 auto; max-width:100%; height: auto;}
1665
+ .test-history-trend .small-axis text {font-size: 11px;}
1666
+ .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
1667
+ .test-history-details-collapsible summary:hover {text-decoration: underline;}
1668
+ .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
1669
+ .test-history-details th, .test-history-details td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--light-gray-color); }
1670
+ .test-history-details th { background-color: var(--light-gray-color); font-weight: 600; }
1671
+ .status-badge-small {
1672
+ padding: 3px 7px; border-radius: 4px; font-size: 0.8em; font-weight: 600;
1673
+ color: white; text-transform: uppercase; display: inline-block;
1674
+ }
1675
+ .status-badge-small.status-passed { background-color: var(--success-color); }
1676
+ .status-badge-small.status-failed { background-color: var(--danger-color); }
1677
+ .status-badge-small.status-skipped { background-color: var(--warning-color); }
1678
+ .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
1679
+
1680
+
1681
+ .no-data, .no-tests, .no-steps, .no-data-chart {
1682
+ padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em;
1683
+ background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0;
1684
+ border: 1px dashed var(--medium-gray-color);
1685
+ }
1686
+ .no-data-chart {font-size: 0.95em; padding: 18px;}
1687
+
1688
+ #test-ai iframe { border: 1px solid var(--border-color); width: 100%; height: 85vh; border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); }
1689
+ #test-ai p {margin-bottom: 18px; font-size: 1em; color: var(--text-color-secondary);}
1690
+ pre .stdout-log { background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1691
+ pre .stderr-log { background-color: #2d2d2d; color: indianred; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1692
+ /* Responsive Enhancements */
1693
+ @media (max-width: 1200px) {
1694
+ .trend-charts-row { grid-template-columns: 1fr; } /* Stack trend charts earlier */
1695
+ }
1696
+ @media (max-width: 992px) {
1697
+ .dashboard-bottom-row { grid-template-columns: 1fr; }
1698
+ .pie-chart-wrapper svg { max-width: 350px; }
1699
+ .filters input { min-width: 180px; }
1700
+ .filters select { min-width: 150px; }
1701
+ }
1702
+ @media (max-width: 768px) {
1703
+ body { font-size: 15px; }
1704
+ .container { margin: 10px; padding: 20px; } /* Adjusted padding */
1705
+ .header { flex-direction: column; align-items: flex-start; gap: 15px; }
1706
+ .header h1 { font-size: 1.6em; }
1707
+ .run-info { text-align: left; font-size:0.9em; }
1708
+ .tabs { margin-bottom: 25px;}
1709
+ .tab-button { padding: 12px 20px; font-size: 1.05em;}
1710
+ .dashboard-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px;}
1711
+ .summary-card .value {font-size: 2em;}
1712
+ .summary-card h3 {font-size: 0.95em;}
1713
+ .filters { flex-direction: column; padding: 18px; gap: 12px;}
1714
+ .filters input, .filters select, .filters button {width: 100%; box-sizing: border-box;}
1715
+ .test-case-header { flex-direction: column; align-items: flex-start; gap: 10px; padding: 14px; }
1716
+ .test-case-summary {gap: 10px;}
1717
+ .test-case-title {font-size: 1.05em;}
1718
+ .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;}
1719
+ .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;}
1720
+ .test-history-grid {grid-template-columns: 1fr;}
1721
+ .pie-chart-wrapper {min-height: auto;} /* Allow pie chart to shrink */
1722
+ .pie-chart-legend-d3 { transform: translate(calc(50% - 50px), calc(100% - 50px));} /* Adjust legend for mobile for pie */
1723
+
1724
+ }
1725
+ @media (max-width: 480px) {
1726
+ body {font-size: 14px;}
1727
+ .container {padding: 15px;}
1728
+ .header h1 {font-size: 1.4em;}
1729
+ #report-logo { height: 35px; width: 35px; }
1730
+ .tab-button {padding: 10px 15px; font-size: 1em;}
1731
+ .summary-card .value {font-size: 1.8em;}
1732
+ .attachments-grid {grid-template-columns: 1fr;}
1733
+ .step-item {padding-left: calc(var(--depth, 0) * 18px);} /* Reduced indent */
1734
+ .test-case-content, .step-details {padding: 15px;}
1735
+ .trend-charts-row {gap: 20px;}
1736
+ .trend-chart {padding: 20px;}
1737
+ .chart-legend-bottom { transform: translate(10px, calc(100% - 50px));} /* Adjust general bottom legend for small screens */
1738
+ .chart-legend-bottom g { transform: translate(0,0) !important;} /* Stack legend items vertically */
1739
+ .chart-legend-bottom g text {font-size: 11px;}
1740
+ .chart-legend-bottom g line, .chart-legend-bottom g circle {transform: scale(0.9);}
1204
1741
  }
1205
- }
1206
- }
1207
1742
  </style>
1208
1743
  </head>
1209
1744
  <body>
1210
1745
  <div class="container">
1211
1746
  <header class="header">
1212
- <div style="display: flex; align-items: center; gap: 15px;">
1213
- <img id="report-logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA3bDggNSA4LTUtOC01eiIgZmlsbD0iIzNmNTEiLz48cGF0aCBkPSJNMTIgNkw0IDExbDggNSA4LTUtOC01eiIgZmlsbD0iIzQyODVmNCIvPjxwYXRoIGQ9Ik0xMiAxMGwtOCA1IDggNSA4LTUtOC01eiIgZmlsbD0iIzNkNTViNCIvPjwvc3ZnPg==" alt="Logo" style="height: 40px;">
1214
- <h1>
1215
- Playwright Pulse Report
1216
- </h1>
1747
+ <div class="header-title">
1748
+ <img id="report-logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA3bDggNSA4LTUtOC01eiIgZmlsbD0iIzNmNTFiNSIvPjxwYXRoIGQ9Ik0xMiA2TDQgMTFsOCA1IDgtNS04LTV6IiBmaWxsPSIjNDI4NWY0Ii8+PHBhdGggZD0iTTEyIDEwbC04IDUgOCA1IDgtNS04LTV6IiBmaWxsPSIjM2Q1NWI0Ii8+PC9zdmc+" alt="Report Logo">
1749
+ <h1>Playwright Pulse Report</h1>
1217
1750
  </div>
1218
1751
  <div class="run-info">
1219
1752
  <strong>Run Date:</strong> ${formatDate(
@@ -1228,57 +1761,57 @@ function generateHTML(reportData) {
1228
1761
  <div class="tabs">
1229
1762
  <button class="tab-button active" data-tab="dashboard">Dashboard</button>
1230
1763
  <button class="tab-button" data-tab="test-runs">Test Run Summary</button>
1764
+ <button class="tab-button" data-tab="test-history">Test History</button>
1231
1765
  <button class="tab-button" data-tab="test-ai">AI Analysis</button>
1232
1766
  </div>
1233
1767
 
1234
1768
  <div id="dashboard" class="tab-content active">
1235
1769
  <div class="dashboard-grid">
1236
1770
  <div class="summary-card">
1237
- <h3>Total Tests</h3>
1238
- <div class="value">${runSummary.totalTests}</div>
1771
+ <h3>Total Tests</h3><div class="value">${
1772
+ runSummary.totalTests
1773
+ }</div>
1239
1774
  </div>
1240
1775
  <div class="summary-card status-passed">
1241
- <h3>Passed</h3>
1242
- <div class="value">${runSummary.passed}</div>
1243
- <div class="trend">${passPercentage}%</div>
1776
+ <h3>Passed</h3><div class="value">${runSummary.passed}</div>
1777
+ <div class="trend-percentage">${passPercentage}%</div>
1244
1778
  </div>
1245
1779
  <div class="summary-card status-failed">
1246
- <h3>Failed</h3>
1247
- <div class="value">${runSummary.failed}</div>
1248
- <div class="trend">${failPercentage}%</div>
1780
+ <h3>Failed</h3><div class="value">${runSummary.failed}</div>
1781
+ <div class="trend-percentage">${failPercentage}%</div>
1249
1782
  </div>
1250
1783
  <div class="summary-card status-skipped">
1251
- <h3>Skipped</h3>
1252
- <div class="value">${runSummary.skipped}</div>
1253
- <div class="trend">${skipPercentage}%</div>
1784
+ <h3>Skipped</h3><div class="value">${
1785
+ runSummary.skipped || 0
1786
+ }</div>
1787
+ <div class="trend-percentage">${skipPercentage}%</div>
1254
1788
  </div>
1255
- </div>
1256
- <div class="dashboard-bottom">
1257
- ${generatePieChartD3([
1258
- { label: "Passed", value: runSummary.passed },
1259
- { label: "Failed", value: runSummary.failed },
1260
- { label: "Skipped", value: runSummary.skipped },
1261
- ])}
1262
- ${generateSuitesWidget(suitesData)}
1263
- <div class="summary-cards">
1264
- <div class="summary-card avg-time">
1265
- <h3>Avg. Time</h3>
1266
- <div class="value">${avgTestDuration}</div>
1267
- </div>
1268
- <div class="summary-card avg-time">
1269
- <h3>Total Time</h3>
1270
- <div class="value">${formatDuration(
1271
- runSummary.duration
1272
- )}</div>
1273
- </div>
1274
- </div>
1789
+ <div class="summary-card">
1790
+ <h3>Avg. Test Time</h3><div class="value">${avgTestDuration}</div>
1791
+ </div>
1792
+ <div class="summary-card">
1793
+ <h3>Run Duration</h3><div class="value">${formatDuration(
1794
+ runSummary.duration
1795
+ )}</div>
1275
1796
  </div>
1276
1797
  </div>
1798
+ <div class="dashboard-bottom-row">
1799
+ ${generatePieChartD3(
1800
+ [
1801
+ { label: "Passed", value: runSummary.passed },
1802
+ { label: "Failed", value: runSummary.failed },
1803
+ { label: "Skipped", value: runSummary.skipped || 0 },
1804
+ ],
1805
+ 400,
1806
+ 350
1807
+ )}
1808
+ ${generateSuitesWidget(suitesData)}
1809
+ </div>
1277
1810
  </div>
1278
1811
 
1279
1812
  <div id="test-runs" class="tab-content">
1280
1813
  <div class="filters">
1281
- <input type="text" id="filter-name" placeholder="Search by test name...">
1814
+ <input type="text" id="filter-name" placeholder="Filter by test name/path..." style="border-color: black; border-style: outset;">
1282
1815
  <select id="filter-status">
1283
1816
  <option value="">All Statuses</option>
1284
1817
  <option value="passed">Passed</option>
@@ -1288,268 +1821,438 @@ function generateHTML(reportData) {
1288
1821
  <select id="filter-browser">
1289
1822
  <option value="">All Browsers</option>
1290
1823
  ${Array.from(
1291
- new Set(results.map((test) => test.browser || "unknown"))
1824
+ new Set(
1825
+ (results || []).map((test) => test.browser || "unknown")
1826
+ )
1292
1827
  )
1293
1828
  .map(
1294
- (browser) => `
1295
- <option value="${browser}">${browser}</option>
1296
- `
1829
+ (browser) =>
1830
+ `<option value="${sanitizeHTML(
1831
+ browser
1832
+ )}">${sanitizeHTML(browser)}</option>`
1297
1833
  )
1298
1834
  .join("")}
1299
1835
  </select>
1300
- <button onclick="expandAllTests()">Expand All</button>
1301
- <button onclick="collapseAllTests()">Collapse All</button>
1836
+ <button id="expand-all-tests">Expand All</button>
1837
+ <button id="collapse-all-tests">Collapse All</button>
1302
1838
  </div>
1303
- <div class="test-suites">
1839
+ <div class="test-cases-list">
1304
1840
  ${generateTestCasesHTML()}
1305
1841
  </div>
1306
1842
  </div>
1843
+
1844
+ <div id="test-history" class="tab-content">
1845
+ <h2 class="tab-main-title">Execution Trends</h2>
1846
+ <div class="trend-charts-row">
1847
+ <div class="trend-chart">
1848
+ <h3 class="chart-title-header">Test Volume & Outcome Trends</h3>
1849
+ ${
1850
+ trendData && trendData.overall && trendData.overall.length > 0
1851
+ ? generateTestTrendsChart(trendData)
1852
+ : '<div class="no-data">Overall trend data not available for test counts.</div>'
1853
+ }
1854
+ </div>
1855
+ <div class="trend-chart">
1856
+ <h3 class="chart-title-header">Execution Duration Trends</h3>
1857
+ ${
1858
+ trendData && trendData.overall && trendData.overall.length > 0
1859
+ ? generateDurationTrendChart(trendData)
1860
+ : '<div class="no-data">Overall trend data not available for durations.</div>'
1861
+ }
1862
+ </div>
1863
+ </div>
1864
+ <h2 class="tab-main-title">Individual Test History</h2>
1865
+ ${
1866
+ trendData &&
1867
+ trendData.testRuns &&
1868
+ Object.keys(trendData.testRuns).length > 0
1869
+ ? generateTestHistoryContent(trendData)
1870
+ : '<div class="no-data">Individual test history data not available.</div>'
1871
+ }
1872
+ </div>
1873
+
1307
1874
  <div id="test-ai" class="tab-content">
1308
- <iframe
1875
+ <iframe
1309
1876
  src="https://ai-test-analyser.netlify.app/"
1310
1877
  width="100%"
1311
- height="600px"
1312
- style="border: none;">
1878
+ height="100%"
1879
+ frameborder="0"
1880
+ allowfullscreen
1881
+ style="border: none; height: 100vh;">
1313
1882
  </iframe>
1314
1883
  </div>
1884
+ <footer style="
1885
+ padding: 0.5rem;
1886
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
1887
+ text-align: center;
1888
+ font-family: 'Segoe UI', system-ui, sans-serif;
1889
+ ">
1890
+ <div style="
1891
+ display: inline-flex;
1892
+ align-items: center;
1893
+ gap: 0.5rem;
1894
+ color: #333;
1895
+ font-size: 0.9rem;
1896
+ font-weight: 600;
1897
+ letter-spacing: 0.5px;
1898
+ ">
1899
+ <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"/>
1900
+ <span>Created by</span>
1901
+ <a href="https://github.com/Arghajit47"
1902
+ target="_blank"
1903
+ rel="noopener noreferrer"
1904
+ style="
1905
+ color: #7737BF;
1906
+ font-weight: 700;
1907
+ font-style: italic;
1908
+ text-decoration: none;
1909
+ transition: all 0.2s ease;
1910
+ "
1911
+ onmouseover="this.style.color='#BF5C37'"
1912
+ onmouseout="this.style.color='#7737BF'">
1913
+ Arghajit Singha
1914
+ </a>
1915
+ </div>
1916
+ <div style="
1917
+ margin-top: 0.5rem;
1918
+ font-size: 0.75rem;
1919
+ color: #666;
1920
+ ">
1921
+ Crafted with precision
1922
+ </div>
1923
+ </footer>
1315
1924
  </div>
1316
1925
 
1317
- <script>
1318
- // Tab switching functionality
1319
- document.querySelectorAll('.tab-button').forEach(button => {
1320
- button.addEventListener('click', () => {
1321
- // Remove active class from all buttons and contents
1322
- document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
1323
- document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
1324
-
1325
- // Add active class to clicked button and corresponding content
1326
- const tabId = button.getAttribute('data-tab');
1327
- button.classList.add('active');
1328
- document.getElementById(tabId).classList.add('active');
1329
- });
1330
- });
1331
1926
 
1332
- // Test filtering functionality
1333
- function setupFilters() {
1334
- const nameFilter = document.getElementById('filter-name');
1335
- const statusFilter = document.getElementById('filter-status');
1336
- const browserFilter = document.getElementById('filter-browser');
1337
-
1338
- const filterTests = () => {
1339
- const nameValue = nameFilter.value.toLowerCase();
1340
- const statusValue = statusFilter.value;
1341
- const browserValue = browserFilter.value;
1342
-
1343
- document.querySelectorAll('.test-suite').forEach(suite => {
1344
- const name = suite.querySelector('.test-name').textContent.toLowerCase();
1345
- const status = suite.getAttribute('data-status');
1346
- const browser = suite.getAttribute('data-browser');
1347
-
1348
- const nameMatch = name.includes(nameValue);
1349
- const statusMatch = !statusValue || status === statusValue;
1350
- const browserMatch = !browserValue || browser === browserValue;
1351
-
1352
- if (nameMatch && statusMatch && browserMatch) {
1353
- suite.style.display = 'block';
1354
- } else {
1355
- suite.style.display = 'none';
1356
- }
1357
- });
1358
- };
1359
-
1360
- nameFilter.addEventListener('input', filterTests);
1361
- statusFilter.addEventListener('change', filterTests);
1362
- browserFilter.addEventListener('change', filterTests);
1363
- }
1364
-
1365
- // Test expansion functionality
1366
- function toggleTestDetails(header) {
1367
- const content = header.nextElementSibling;
1368
- content.style.display = content.style.display === 'block' ? 'none' : 'block';
1369
- }
1370
-
1371
- // Step expansion functionality
1372
- function toggleStepDetails(header) {
1373
- const details = header.nextElementSibling;
1374
- details.style.display = details.style.display === 'block' ? 'none' : 'block';
1375
-
1376
- // Toggle nested steps if they exist
1377
- const nestedSteps = header.parentElement.querySelector('.nested-steps');
1378
- if (nestedSteps) {
1379
- nestedSteps.style.display = nestedSteps.style.display === 'block' ? 'none' : 'block';
1380
- }
1381
- }
1382
-
1383
- // Expand all tests
1384
- function expandAllTests() {
1385
- document.querySelectorAll('.suite-content').forEach(el => {
1386
- el.style.display = 'block';
1387
- });
1388
- document.querySelectorAll('.step-details').forEach(el => {
1389
- el.style.display = 'block';
1390
- });
1391
- document.querySelectorAll('.nested-steps').forEach(el => {
1392
- el.style.display = 'block';
1393
- });
1394
- }
1395
-
1396
- // Collapse all tests
1397
- function collapseAllTests() {
1398
- document.querySelectorAll('.suite-content').forEach(el => {
1399
- el.style.display = 'none';
1400
- });
1401
- document.querySelectorAll('.step-details').forEach(el => {
1402
- el.style.display = 'none';
1403
- });
1404
- document.querySelectorAll('.nested-steps').forEach(el => {
1405
- el.style.display = 'none';
1406
- });
1407
- }
1408
-
1409
- // Initialize everything when DOM is loaded
1410
- document.addEventListener('DOMContentLoaded', () => {
1411
- setupFilters();
1412
-
1413
- // Make step headers clickable
1414
- document.querySelectorAll('.step-header').forEach(header => {
1415
- header.addEventListener('click', function() {
1416
- toggleStepDetails(this);
1417
- });
1418
- });
1419
-
1420
- // Make test headers clickable
1421
- document.querySelectorAll('.suite-header').forEach(header => {
1422
- header.addEventListener('click', function() {
1423
- toggleTestDetails(this);
1927
+ <script>
1928
+ function initializeReportInteractivity() {
1929
+ const tabButtons = document.querySelectorAll('.tab-button');
1930
+ const tabContents = document.querySelectorAll('.tab-content');
1931
+ tabButtons.forEach(button => {
1932
+ button.addEventListener('click', () => {
1933
+ tabButtons.forEach(btn => btn.classList.remove('active'));
1934
+ tabContents.forEach(content => content.classList.remove('active'));
1935
+ button.classList.add('active');
1936
+ const tabId = button.getAttribute('data-tab');
1937
+ const activeContent = document.getElementById(tabId);
1938
+ if (activeContent) activeContent.classList.add('active');
1939
+ });
1424
1940
  });
1425
- });
1426
- });
1427
1941
 
1428
- // Enhanced expand/collapse functionality
1429
- function toggleTestDetails(header) {
1430
- const content = header.nextElementSibling;
1431
- const isExpanded = content.style.display === 'block';
1432
- content.style.display = isExpanded ? 'none' : 'block';
1433
- header.setAttribute('aria-expanded', !isExpanded);
1434
- }
1435
-
1436
- function toggleStepDetails(header) {
1437
- const details = header.nextElementSibling;
1438
- const nestedSteps = header.parentElement.querySelector('.nested-steps');
1439
-
1440
- // Toggle main step details
1441
- const isExpanded = details.style.display === 'block';
1442
- details.style.display = isExpanded ? 'none' : 'block';
1942
+ const nameFilter = document.getElementById('filter-name');
1943
+ const statusFilter = document.getElementById('filter-status');
1944
+ const browserFilter = document.getElementById('filter-browser');
1945
+
1946
+ function filterTestCases() {
1947
+ const nameValue = nameFilter ? nameFilter.value.toLowerCase() : "";
1948
+ const statusValue = statusFilter ? statusFilter.value : "";
1949
+ const browserValue = browserFilter ? browserFilter.value : "";
1950
+
1951
+ document.querySelectorAll('#test-runs .test-case').forEach(testCaseElement => {
1952
+ const titleElement = testCaseElement.querySelector('.test-case-title');
1953
+ // Use the 'title' attribute of .test-case-title for full path filtering
1954
+ const fullTestName = titleElement ? titleElement.getAttribute('title').toLowerCase() : "";
1955
+ const status = testCaseElement.getAttribute('data-status');
1956
+ const browser = testCaseElement.getAttribute('data-browser');
1957
+
1958
+ const nameMatch = fullTestName.includes(nameValue);
1959
+ const statusMatch = !statusValue || status === statusValue;
1960
+ const browserMatch = !browserValue || browser === browserValue;
1961
+
1962
+ testCaseElement.style.display = (nameMatch && statusMatch && browserMatch) ? '' : 'none';
1963
+ });
1964
+ }
1965
+ if(nameFilter) nameFilter.addEventListener('input', filterTestCases);
1966
+ if(statusFilter) statusFilter.addEventListener('change', filterTestCases);
1967
+ if(browserFilter) browserFilter.addEventListener('change', filterTestCases);
1968
+
1969
+ const historyNameFilter = document.getElementById('history-filter-name');
1970
+ const historyStatusFilter = document.getElementById('history-filter-status');
1971
+
1972
+ function filterTestHistoryCards() {
1973
+ const nameValue = historyNameFilter ? historyNameFilter.value.toLowerCase() : "";
1974
+ const statusValue = historyStatusFilter ? historyStatusFilter.value : "";
1975
+
1976
+ document.querySelectorAll('.test-history-card').forEach(card => {
1977
+ // data-test-name now holds the test title (last part of full name)
1978
+ const testTitle = card.getAttribute('data-test-name').toLowerCase();
1979
+ const latestStatus = card.getAttribute('data-latest-status');
1980
+
1981
+ const nameMatch = testTitle.includes(nameValue);
1982
+ const statusMatch = !statusValue || latestStatus === statusValue;
1983
+
1984
+ card.style.display = (nameMatch && statusMatch) ? '' : 'none';
1985
+ });
1986
+ }
1987
+ if(historyNameFilter) historyNameFilter.addEventListener('input', filterTestHistoryCards);
1988
+ if(historyStatusFilter) historyStatusFilter.addEventListener('change', filterTestHistoryCards);
1989
+
1990
+ function toggleElementDetails(headerElement, contentSelector) {
1991
+ let contentElement;
1992
+ // For test cases, content is a child of the header's parent.
1993
+ // For steps, content is the direct next sibling.
1994
+ if (headerElement.classList.contains('test-case-header')) {
1995
+ contentElement = headerElement.parentElement.querySelector('.test-case-content');
1996
+ } else if (headerElement.classList.contains('step-header')) {
1997
+ contentElement = headerElement.nextElementSibling;
1998
+ // Verify it's the correct details div
1999
+ if (!contentElement || !contentElement.matches(contentSelector || '.step-details')) {
2000
+ contentElement = null;
2001
+ }
2002
+ }
1443
2003
 
1444
- // Toggle nested steps if they exist
1445
- if (nestedSteps) {
1446
- nestedSteps.style.display = isExpanded ? 'none' : 'block';
1447
- }
1448
-
1449
- header.setAttribute('aria-expanded', !isExpanded);
1450
- }
2004
+ if (contentElement) {
2005
+ const isExpanded = contentElement.style.display === 'block';
2006
+ contentElement.style.display = isExpanded ? 'none' : 'block';
2007
+ headerElement.setAttribute('aria-expanded', String(!isExpanded));
2008
+ }
2009
+ }
2010
+
2011
+ document.querySelectorAll('#test-runs .test-case-header').forEach(header => {
2012
+ header.addEventListener('click', () => toggleElementDetails(header));
2013
+ });
2014
+ document.querySelectorAll('#test-runs .step-header').forEach(header => {
2015
+ header.addEventListener('click', () => toggleElementDetails(header, '.step-details'));
2016
+ });
1451
2017
 
1452
- function expandAllTests() {
1453
- document.querySelectorAll('.suite-content').forEach(el => {
1454
- el.style.display = 'block';
1455
- });
1456
- document.querySelectorAll('.step-details').forEach(el => {
1457
- el.style.display = 'block';
1458
- });
1459
- document.querySelectorAll('.nested-steps').forEach(el => {
1460
- el.style.display = 'block';
1461
- });
1462
- document.querySelectorAll('[aria-expanded]').forEach(el => {
1463
- el.setAttribute('aria-expanded', 'true');
1464
- });
1465
- }
2018
+ const expandAllBtn = document.getElementById('expand-all-tests');
2019
+ const collapseAllBtn = document.getElementById('collapse-all-tests');
1466
2020
 
1467
- function collapseAllTests() {
1468
- document.querySelectorAll('.suite-content').forEach(el => {
1469
- el.style.display = 'none';
1470
- });
1471
- document.querySelectorAll('.step-details').forEach(el => {
1472
- el.style.display = 'none';
1473
- });
1474
- document.querySelectorAll('.nested-steps').forEach(el => {
1475
- el.style.display = 'none';
1476
- });
1477
- document.querySelectorAll('[aria-expanded]').forEach(el => {
1478
- el.setAttribute('aria-expanded', 'false');
1479
- });
2021
+ function setAllTestRunDetailsVisibility(displayMode, ariaState) {
2022
+ document.querySelectorAll('#test-runs .test-case-content').forEach(el => el.style.display = displayMode);
2023
+ document.querySelectorAll('#test-runs .step-details').forEach(el => el.style.display = displayMode);
2024
+ document.querySelectorAll('#test-runs .test-case-header[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', ariaState));
2025
+ document.querySelectorAll('#test-runs .step-header[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', ariaState));
2026
+ }
2027
+
2028
+ if (expandAllBtn) expandAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('block', 'true'));
2029
+ if (collapseAllBtn) collapseAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('none', 'false'));
1480
2030
  }
1481
-
1482
- // Initialize all interactive elements
1483
- function initializeInteractiveElements() {
1484
- // Test headers
1485
- document.querySelectorAll('.suite-header').forEach(header => {
1486
- header.addEventListener('click', () => toggleTestDetails(header));
1487
- header.setAttribute('role', 'button');
1488
- header.setAttribute('aria-expanded', 'false');
1489
- });
1490
-
1491
- // Step headers
1492
- document.querySelectorAll('.step-header').forEach(header => {
1493
- header.addEventListener('click', () => toggleStepDetails(header));
1494
- header.setAttribute('role', 'button');
1495
- header.setAttribute('aria-expanded', 'false');
1496
- });
1497
-
1498
- // Filter buttons
1499
- document.getElementById('filter-name').addEventListener('input', filterTests);
1500
- document.getElementById('filter-status').addEventListener('change', filterTests);
1501
- document.getElementById('filter-browser').addEventListener('change', filterTests);
1502
- }
1503
-
1504
- // Initialize when DOM is loaded
1505
- document.addEventListener('DOMContentLoaded', initializeInteractiveElements);
2031
+ document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
1506
2032
  </script>
1507
2033
  </body>
1508
2034
  </html>
1509
2035
  `;
1510
2036
  }
1511
2037
 
1512
- // [Keep the rest of the file unchanged...]
2038
+ // Add this helper function somewhere in generate-static-report.mjs,
2039
+ // possibly before your main() function.
2040
+
2041
+ async function runScript(scriptPath) {
2042
+ return new Promise((resolve, reject) => {
2043
+ console.log(chalk.blue(`Executing script: ${scriptPath}...`));
2044
+ const process = fork(scriptPath, [], {
2045
+ stdio: "inherit", // This will pipe the child process's stdio to the parent
2046
+ });
2047
+
2048
+ process.on("error", (err) => {
2049
+ console.error(chalk.red(`Failed to start script: ${scriptPath}`), err);
2050
+ reject(err);
2051
+ });
1513
2052
 
1514
- // [Rest of the file remains the same...]
2053
+ process.on("exit", (code) => {
2054
+ if (code === 0) {
2055
+ console.log(chalk.green(`Script ${scriptPath} finished successfully.`));
2056
+ resolve();
2057
+ } else {
2058
+ const errorMessage = `Script ${scriptPath} exited with code ${code}.`;
2059
+ console.error(chalk.red(errorMessage));
2060
+ reject(new Error(errorMessage));
2061
+ }
2062
+ });
2063
+ });
2064
+ }
1515
2065
 
1516
- // Main execution function
1517
2066
  async function main() {
2067
+ const __filename = fileURLToPath(import.meta.url); // Get current file path
2068
+ const __dirname = path.dirname(__filename); // Get current directory
2069
+ const trendExcelScriptPath = path.resolve(
2070
+ __dirname,
2071
+ "generate-trend-excel.mjs"
2072
+ ); // generate-trend-excel.mjs is in the SAME directory as generate-static-report.mjs
1518
2073
  const outputDir = path.resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
1519
2074
  const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE);
1520
2075
  const reportHtmlPath = path.resolve(outputDir, DEFAULT_HTML_FILE);
2076
+ const trendDataPath = path.resolve(outputDir, "trend.xls");
2077
+
2078
+ console.log(chalk.blue(`Starting static HTML report generation...`));
2079
+ console.log(chalk.blue(`Output directory set to: ${outputDir}`));
1521
2080
 
1522
- console.log(chalk.blue(`Generating enhanced static report in: ${outputDir}`));
2081
+ // --- Step 1: Ensure Excel trend data is generated/updated FIRST ---
2082
+ try {
2083
+ await runScript(trendExcelScriptPath);
2084
+ console.log(chalk.green("Excel trend generation completed."));
2085
+ } catch (error) {
2086
+ console.error(
2087
+ chalk.red(
2088
+ "Failed to generate/update Excel trend data. HTML report might use stale or no trend data."
2089
+ ),
2090
+ error
2091
+ );
2092
+ }
1523
2093
 
1524
2094
  let reportData;
1525
2095
  try {
1526
2096
  const jsonData = await fs.readFile(reportJsonPath, "utf-8");
1527
2097
  reportData = JSON.parse(jsonData);
2098
+ if (!reportData || typeof reportData !== "object" || !reportData.results) {
2099
+ throw new Error(
2100
+ "Invalid report JSON structure. 'results' field is missing or invalid."
2101
+ );
2102
+ }
2103
+ if (!Array.isArray(reportData.results)) {
2104
+ reportData.results = [];
2105
+ console.warn(
2106
+ chalk.yellow(
2107
+ "Warning: 'results' field in JSON was not an array. Treated as empty."
2108
+ )
2109
+ );
2110
+ }
2111
+ } catch (error) {
2112
+ console.error(
2113
+ chalk.red(`Error reading or parsing main report JSON: ${error.message}`)
2114
+ );
2115
+ process.exit(1);
2116
+ }
2117
+
2118
+ let trendData = { overall: [], testRuns: {} };
2119
+ try {
2120
+ await fs.access(trendDataPath);
2121
+ const excelBuffer = await fs.readFile(trendDataPath);
2122
+ const workbook = XLSX.read(excelBuffer, { type: "buffer" });
2123
+
2124
+ if (workbook.Sheets["overall"]) {
2125
+ trendData.overall = XLSX.utils
2126
+ .sheet_to_json(workbook.Sheets["overall"])
2127
+ .map((row) => {
2128
+ let timestamp;
2129
+ if (typeof row.TIMESTAMP === "number") {
2130
+ if (XLSX.SSF && typeof XLSX.SSF.parse_date_code === "function") {
2131
+ try {
2132
+ timestamp = XLSX.SSF.parse_date_code(row.TIMESTAMP);
2133
+ } catch (e) {
2134
+ console.warn(
2135
+ chalk.yellow(
2136
+ ` - Could not parse Excel date number ${row.TIMESTAMP} for RUN_ID ${row.RUN_ID}. Using current time. Error: ${e.message}`
2137
+ )
2138
+ );
2139
+ timestamp = new Date(Date.now());
2140
+ }
2141
+ } else {
2142
+ console.warn(
2143
+ chalk.yellow(
2144
+ ` - XLSX.SSF.parse_date_code is unavailable for RUN_ID ${row.RUN_ID}. Numeric TIMESTAMP ${row.TIMESTAMP} treated as direct JS timestamp or fallback.`
2145
+ )
2146
+ );
2147
+ timestamp = new Date(
2148
+ row.TIMESTAMP > 0 && row.TIMESTAMP < 3000000000000
2149
+ ? row.TIMESTAMP
2150
+ : Date.now()
2151
+ ); // Heuristic for JS timestamp
2152
+ }
2153
+ } else if (row.TIMESTAMP) {
2154
+ timestamp = new Date(row.TIMESTAMP);
2155
+ } else {
2156
+ timestamp = new Date(Date.now());
2157
+ }
2158
+
2159
+ return {
2160
+ runId: Number(row.RUN_ID) || 0,
2161
+ duration: Number(row.DURATION) || 0,
2162
+ timestamp: timestamp,
2163
+ totalTests: Number(row.TOTAL_TESTS) || 0,
2164
+ passed: Number(row.PASSED) || 0,
2165
+ failed: Number(row.FAILED) || 0,
2166
+ skipped: Number(row.SKIPPED) || 0, // Ensure skipped is always a number
2167
+ };
2168
+ })
2169
+ .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
2170
+ }
2171
+
2172
+ workbook.SheetNames.forEach((sheetName) => {
2173
+ if (sheetName.toLowerCase().startsWith("test run ")) {
2174
+ trendData.testRuns[sheetName] = XLSX.utils
2175
+ .sheet_to_json(workbook.Sheets[sheetName])
2176
+ .map((test) => {
2177
+ let timestamp;
2178
+ if (typeof test.TIMESTAMP === "number") {
2179
+ if (XLSX.SSF && typeof XLSX.SSF.parse_date_code === "function") {
2180
+ try {
2181
+ timestamp = XLSX.SSF.parse_date_code(test.TIMESTAMP);
2182
+ } catch (e) {
2183
+ timestamp = new Date(Date.now());
2184
+ }
2185
+ } else {
2186
+ timestamp = new Date(
2187
+ test.TIMESTAMP > 0 && test.TIMESTAMP < 3000000000000
2188
+ ? test.TIMESTAMP
2189
+ : Date.now()
2190
+ );
2191
+ } // Heuristic
2192
+ } else if (test.TIMESTAMP) {
2193
+ timestamp = new Date(test.TIMESTAMP);
2194
+ } else {
2195
+ timestamp = new Date(Date.now());
2196
+ }
2197
+ return {
2198
+ testName: String(test.TEST_NAME || "Unknown Test"),
2199
+ duration: Number(test.DURATION) || 0,
2200
+ status: String(test.STATUS || "unknown").toLowerCase(),
2201
+ timestamp: timestamp,
2202
+ };
2203
+ });
2204
+ }
2205
+ });
1528
2206
  if (
1529
- !reportData ||
1530
- typeof reportData !== "object" ||
1531
- !Array.isArray(reportData.results)
2207
+ trendData.overall.length > 0 ||
2208
+ Object.keys(trendData.testRuns).length > 0
1532
2209
  ) {
1533
- throw new Error("Invalid report JSON structure.");
2210
+ console.log(
2211
+ chalk.green(`Trend data loaded successfully from: ${trendDataPath}`)
2212
+ );
2213
+ } else {
2214
+ console.warn(
2215
+ chalk.yellow(
2216
+ `Trend data file found at ${trendDataPath}, but no data was loaded from 'overall' or 'test run' sheets.`
2217
+ )
2218
+ );
1534
2219
  }
1535
2220
  } catch (error) {
1536
- console.error(chalk.red(`Error: ${error.message}`));
1537
- process.exit(1);
2221
+ if (error.code === "ENOENT") {
2222
+ console.warn(
2223
+ chalk.yellow(
2224
+ `Warning: Trend data file not found at ${trendDataPath}. Report will be generated without historical trends.`
2225
+ )
2226
+ );
2227
+ } else {
2228
+ console.warn(
2229
+ chalk.yellow(
2230
+ `Warning: Could not read or process trend data from ${trendDataPath}. Report will be generated without historical trends. Error: ${error.message}`
2231
+ )
2232
+ );
2233
+ }
1538
2234
  }
1539
2235
 
1540
2236
  try {
1541
- const htmlContent = generateHTML(reportData);
2237
+ const htmlContent = generateHTML(reportData, trendData);
1542
2238
  await fs.writeFile(reportHtmlPath, htmlContent, "utf-8");
1543
2239
  console.log(
1544
- chalk.green(`Report generated successfully at: ${reportHtmlPath}`)
2240
+ chalk.green.bold(
2241
+ `🎉 Enhanced report generated successfully at: ${reportHtmlPath}`
2242
+ )
1545
2243
  );
1546
- console.log(chalk.blue(`You can open it in your browser with:`));
1547
- console.log(chalk.blue(`open ${reportHtmlPath}`));
2244
+ console.log(chalk.gray(` (You can open this file in your browser)`));
1548
2245
  } catch (error) {
1549
- console.error(chalk.red(`Error: ${error.message}`));
2246
+ console.error(chalk.red(`Error generating HTML report: ${error.message}`));
2247
+ console.error(chalk.red(error.stack));
1550
2248
  process.exit(1);
1551
2249
  }
1552
2250
  }
1553
2251
 
1554
- // Run the main function
1555
- main();
2252
+ main().catch((err) => {
2253
+ console.error(
2254
+ chalk.red.bold(`Unhandled error during script execution: ${err.message}`)
2255
+ );
2256
+ console.error(err.stack);
2257
+ process.exit(1);
2258
+ });