@arghajit/playwright-pulse-report 0.2.0 → 0.2.2

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