@arghajit/playwright-pulse-report 0.2.0 → 0.2.1

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,7 @@
1
1
  #!/usr/bin/env node
2
- // Using Node.js syntax compatible with `.mjs`
2
+
3
3
  import * as fs from "fs/promises";
4
4
  import path from "path";
5
- import * as d3 from "d3";
6
- import { JSDOM } from "jsdom";
7
- import * as XLSX from "xlsx";
8
5
  import { fork } from "child_process"; // Add this
9
6
  import { fileURLToPath } from "url"; // Add this for resolving path in ESM
10
7
 
@@ -31,7 +28,7 @@ const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
31
28
 
32
29
  // Helper functions
33
30
  function sanitizeHTML(str) {
34
- // CORRECTED VERSION
31
+ // User's provided version (note: this doesn't escape HTML special chars correctly)
35
32
  if (str === null || str === undefined) return "";
36
33
  return String(str)
37
34
  .replace(/&/g, "&")
@@ -45,494 +42,328 @@ function capitalize(str) {
45
42
  return str[0].toUpperCase() + str.slice(1).toLowerCase();
46
43
  }
47
44
 
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";
52
- }
45
+ function formatPlaywrightError(error) {
46
+ // Get the error message and clean ANSI codes
47
+ const rawMessage = error.stack || error.message || error.toString();
48
+ const cleanMessage = rawMessage.replace(/\x1B\[[0-9;]*[mGKH]/g, "");
53
49
 
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>';
50
+ // Parse error components
51
+ const timeoutMatch = cleanMessage.match(/Timed out (\d+)ms waiting for/);
52
+ const assertionMatch = cleanMessage.match(/expect\((.*?)\)\.(.*?)\(/);
53
+ const expectedMatch = cleanMessage.match(
54
+ /Expected (?:pattern|string|regexp|value): (.*?)(?:\n|Call log|$)/s
55
+ );
56
+ const actualMatch = cleanMessage.match(
57
+ /Received (?:string|value): (.*?)(?:\n|Call log|$)/s
58
+ );
59
+
60
+ // HTML escape function
61
+ const escapeHtml = (str) => {
62
+ if (!str) return "";
63
+ return str.replace(
64
+ /[&<>'"]/g,
65
+ (tag) =>
66
+ ({
67
+ "&": "&amp;",
68
+ "<": "&lt;",
69
+ ">": "&gt;",
70
+ "'": "&#39;",
71
+ '"': "&quot;",
72
+ }[tag])
73
+ );
74
+ };
75
+
76
+ // Build HTML output
77
+ let html = `<div class="playwright-error">
78
+ <div class="error-header">Test Error</div>`;
79
+
80
+ if (timeoutMatch) {
81
+ html += `<div class="error-timeout">⏱ Timeout: ${escapeHtml(
82
+ timeoutMatch[1]
83
+ )}ms</div>`;
57
84
  }
58
85
 
59
- const { document } = new JSDOM().window;
60
- const body = d3.select(document.body);
86
+ if (assertionMatch) {
87
+ html += `<div class="error-assertion">🔍 Assertion: expect(${escapeHtml(
88
+ assertionMatch[1]
89
+ )}).${escapeHtml(assertionMatch[2])}()</div>`;
90
+ }
61
91
 
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;
92
+ if (expectedMatch) {
93
+ html += `<div class="error-expected">✅ Expected: ${escapeHtml(
94
+ expectedMatch[1]
95
+ )}</div>`;
96
+ }
66
97
 
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");
98
+ if (actualMatch) {
99
+ html += `<div class="error-actual">❌ Actual: ${escapeHtml(
100
+ actualMatch[1]
101
+ )}</div>`;
102
+ }
76
103
 
77
- const chart = svg
78
- .append("g")
79
- .attr("transform", `translate(${margin.left},${margin.top})`);
104
+ // Add call log if present
105
+ const callLogStart = cleanMessage.indexOf("Call log:");
106
+ if (callLogStart !== -1) {
107
+ const callLogEnd =
108
+ cleanMessage.indexOf("\n\n", callLogStart) || cleanMessage.length;
109
+ const callLogSection = cleanMessage
110
+ .slice(callLogStart + 9, callLogEnd)
111
+ .trim();
112
+
113
+ html += `<div class="error-call-log">
114
+ <div class="call-log-header">📜 Call Log:</div>
115
+ <ul class="call-log-items">${callLogSection
116
+ .split("\n")
117
+ .map((line) => line.trim())
118
+ .filter((line) => line)
119
+ .map((line) => `<li>${escapeHtml(line.replace(/^-\s*/, ""))}</li>`)
120
+ .join("")}</ul>
121
+ </div>`;
122
+ }
80
123
 
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);
124
+ // Add stack trace if present
125
+ const stackTraceMatch = cleanMessage.match(/\n\s*at\s.*/gs);
126
+ if (stackTraceMatch) {
127
+ html += `<div class="error-stack-trace">
128
+ <div class="stack-trace-header">🔎 Stack Trace:</div>
129
+ <ul class="stack-trace-items">${stackTraceMatch[0]
130
+ .trim()
131
+ .split("\n")
132
+ .map((line) => line.trim())
133
+ .filter((line) => line)
134
+ .map((line) => `<li>${escapeHtml(line)}</li>`)
135
+ .join("")}</ul>
136
+ </div>`;
137
+ }
86
138
 
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
- ];
139
+ html += `</div>`;
128
140
 
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
- });
141
+ return html;
142
+ }
148
143
 
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
- );
144
+ // User-provided formatDuration function
145
+ function formatDuration(ms) {
146
+ if (ms === undefined || ms === null || ms < 0) return "0.0s";
147
+ return (ms / 1000).toFixed(1) + "s";
148
+ }
182
149
 
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
- });
150
+ function generateTestTrendsChart(trendData) {
151
+ if (!trendData || !trendData.overall || trendData.overall.length === 0) {
152
+ return '<div class="no-data">No overall trend data available for test counts.</div>';
153
+ }
269
154
 
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
- });
155
+ const chartId = `testTrendsChart-${Date.now()}-${Math.random()
156
+ .toString(36)
157
+ .substring(2, 7)}`;
158
+ const runs = trendData.overall;
285
159
 
286
- // Legend
287
- const legendData = [
160
+ const series = [
288
161
  {
289
- label: "Total",
290
- colorClass: "total-line",
291
- dotColor: "var(--primary-color)",
162
+ name: "Total",
163
+ data: runs.map((r) => r.totalTests),
164
+ color: "var(--primary-color)", // Blue
165
+ marker: { symbol: "circle" },
292
166
  },
293
167
  {
294
- label: "Passed",
295
- colorClass: "passed-line",
296
- dotColor: "var(--success-color)",
168
+ name: "Passed",
169
+ data: runs.map((r) => r.passed),
170
+ color: "var(--success-color)", // Green
171
+ marker: { symbol: "circle" },
297
172
  },
298
173
  {
299
- label: "Failed",
300
- colorClass: "failed-line",
301
- dotColor: "var(--danger-color)",
174
+ name: "Failed",
175
+ data: runs.map((r) => r.failed),
176
+ color: "var(--danger-color)", // Red
177
+ marker: { symbol: "circle" },
302
178
  },
303
179
  {
304
- label: "Skipped",
305
- colorClass: "skipped-line",
306
- dotColor: "var(--warning-color)",
180
+ name: "Skipped",
181
+ data: runs.map((r) => r.skipped || 0),
182
+ color: "var(--warning-color)", // Yellow
183
+ marker: { symbol: "circle" },
307
184
  },
308
185
  ];
309
186
 
310
- const legend = chart
311
- .append("g")
312
- .attr("class", "chart-legend-d3 chart-legend-bottom")
313
- .attr(
314
- "transform",
315
- `translate(${width / 2 - (legendData.length * 80) / 2}, ${height + 40})`
316
- );
317
-
318
- legendData.forEach((item, i) => {
319
- const row = legend.append("g").attr("transform", `translate(${i * 80}, 0)`);
320
- row
321
- .append("line")
322
- .attr("x1", 0)
323
- .attr("x2", 15)
324
- .attr("y1", 5)
325
- .attr("y2", 5)
326
- .attr("class", `chart-line ${item.colorClass}`)
327
- .style("stroke-width", 2.5);
328
- row
329
- .append("circle")
330
- .attr("cx", 7.5)
331
- .attr("cy", 5)
332
- .attr("r", 3.5)
333
- .style("fill", item.dotColor);
334
- row
335
- .append("text")
336
- .attr("x", 22)
337
- .attr("y", 10)
338
- .text(item.label)
339
- .style("font-size", "12px");
340
- });
187
+ // Data needed by the tooltip formatter, stringified to be embedded in the client-side script
188
+ const runsForTooltip = runs.map((r) => ({
189
+ runId: r.runId,
190
+ timestamp: r.timestamp,
191
+ duration: r.duration,
192
+ }));
193
+
194
+ const optionsObjectString = `
195
+ {
196
+ chart: { type: "line", height: 350, backgroundColor: "transparent" },
197
+ title: { text: null },
198
+ xAxis: {
199
+ categories: ${JSON.stringify(runs.map((run, i) => `Run ${i + 1}`))},
200
+ crosshair: true,
201
+ labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}
202
+ },
203
+ yAxis: {
204
+ title: { text: "Test Count", style: { color: 'var(--text-color)'} },
205
+ min: 0,
206
+ labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}
207
+ },
208
+ legend: {
209
+ layout: "horizontal", align: "center", verticalAlign: "bottom",
210
+ itemStyle: { fontSize: "12px", color: 'var(--text-color)' }
211
+ },
212
+ plotOptions: {
213
+ series: { marker: { radius: 4, states: { hover: { radius: 6 }}}, states: { hover: { halo: { size: 5, opacity: 0.1 }}}},
214
+ line: { lineWidth: 2.5 } // fillOpacity was 0.1, but for line charts, area fill is usually separate (area chart type)
215
+ },
216
+ tooltip: {
217
+ shared: true, useHTML: true,
218
+ backgroundColor: 'rgba(10,10,10,0.92)',
219
+ borderColor: 'rgba(10,10,10,0.92)',
220
+ style: { color: '#f5f5f5' },
221
+ formatter: function () {
222
+ const runsData = ${JSON.stringify(runsForTooltip)};
223
+ const pointIndex = this.points[0].point.x; // Get index from point
224
+ const run = runsData[pointIndex];
225
+ let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' +
226
+ 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br><br>';
227
+ this.points.forEach(point => {
228
+ tooltip += '<span style="color:' + point.color + '">●</span> ' + point.series.name + ': <b>' + point.y + '</b><br>';
229
+ });
230
+ tooltip += '<br>Duration: ' + formatDuration(run.duration);
231
+ return tooltip;
232
+ }
233
+ },
234
+ series: ${JSON.stringify(series)},
235
+ credits: { enabled: false }
236
+ }
237
+ `;
341
238
 
342
- return `<div class="trend-chart-container">${body.html()}</div>`;
239
+ return `
240
+ <div id="${chartId}" class="trend-chart-container"></div>
241
+ <script>
242
+ document.addEventListener('DOMContentLoaded', function() {
243
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
244
+ try {
245
+ const chartOptions = ${optionsObjectString};
246
+ Highcharts.chart('${chartId}', chartOptions);
247
+ } catch (e) {
248
+ console.error("Error rendering chart ${chartId}:", e);
249
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering test trends chart.</div>';
250
+ }
251
+ } else {
252
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
253
+ }
254
+ });
255
+ </script>
256
+ `;
343
257
  }
344
258
 
345
259
  function generateDurationTrendChart(trendData) {
346
260
  if (!trendData || !trendData.overall || trendData.overall.length === 0) {
347
261
  return '<div class="no-data">No overall trend data available for durations.</div>';
348
262
  }
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
-
263
+ const chartId = `durationTrendChart-${Date.now()}-${Math.random()
264
+ .toString(36)
265
+ .substring(2, 7)}`;
372
266
  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
267
 
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
- });
268
+ // Assuming var(--accent-color-alt) is Orange #FF9800
269
+ const accentColorAltRGB = "255, 152, 0";
270
+
271
+ const seriesString = `[{
272
+ name: 'Duration',
273
+ data: ${JSON.stringify(runs.map((run) => run.duration))},
274
+ color: 'var(--accent-color-alt)',
275
+ type: 'area',
276
+ marker: {
277
+ symbol: 'circle', enabled: true, radius: 4,
278
+ states: { hover: { radius: 6, lineWidthPlus: 0 } }
279
+ },
280
+ fillColor: {
281
+ linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
282
+ stops: [
283
+ [0, 'rgba(${accentColorAltRGB}, 0.4)'],
284
+ [1, 'rgba(${accentColorAltRGB}, 0.05)']
285
+ ]
286
+ },
287
+ lineWidth: 2.5
288
+ }]`;
289
+
290
+ const runsForTooltip = runs.map((r) => ({
291
+ runId: r.runId,
292
+ timestamp: r.timestamp,
293
+ duration: r.duration,
294
+ totalTests: r.totalTests,
295
+ }));
296
+
297
+ const optionsObjectString = `
298
+ {
299
+ chart: { type: 'area', height: 350, backgroundColor: 'transparent' },
300
+ title: { text: null },
301
+ xAxis: {
302
+ categories: ${JSON.stringify(runs.map((run, i) => `Run ${i + 1}`))},
303
+ crosshair: true,
304
+ labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' } }
305
+ },
306
+ yAxis: {
307
+ title: { text: 'Duration', style: { color: 'var(--text-color)' } },
308
+ labels: {
309
+ formatter: function() { return formatDuration(this.value); },
310
+ style: { color: 'var(--text-color-secondary)', fontSize: '12px' }
311
+ },
312
+ min: 0
313
+ },
314
+ legend: {
315
+ layout: 'horizontal', align: 'center', verticalAlign: 'bottom',
316
+ itemStyle: { fontSize: '12px', color: 'var(--text-color)' }
317
+ },
318
+ plotOptions: {
319
+ area: {
320
+ lineWidth: 2.5,
321
+ states: { hover: { lineWidthPlus: 0 } },
322
+ threshold: null
323
+ }
324
+ },
325
+ tooltip: {
326
+ shared: true, useHTML: true,
327
+ backgroundColor: 'rgba(10,10,10,0.92)',
328
+ borderColor: 'rgba(10,10,10,0.92)',
329
+ style: { color: '#f5f5f5' },
330
+ formatter: function () {
331
+ const runsData = ${JSON.stringify(runsForTooltip)};
332
+ const pointIndex = this.points[0].point.x;
333
+ const run = runsData[pointIndex];
334
+ let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' +
335
+ 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br>';
336
+ this.points.forEach(point => {
337
+ tooltip += '<span style="color:' + point.series.color + '">●</span> ' +
338
+ point.series.name + ': <b>' + formatDuration(point.y) + '</b><br>';
339
+ });
340
+ tooltip += '<br>Tests: ' + run.totalTests;
341
+ return tooltip;
342
+ }
343
+ },
344
+ series: ${seriesString},
345
+ credits: { enabled: false }
346
+ }
347
+ `;
500
348
 
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>`;
349
+ return `
350
+ <div id="${chartId}" class="trend-chart-container"></div>
351
+ <script>
352
+ document.addEventListener('DOMContentLoaded', function() {
353
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
354
+ try {
355
+ const chartOptions = ${optionsObjectString};
356
+ Highcharts.chart('${chartId}', chartOptions);
357
+ } catch (e) {
358
+ console.error("Error rendering chart ${chartId}:", e);
359
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering duration trend chart.</div>';
360
+ }
361
+ } else {
362
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
363
+ }
364
+ });
365
+ </script>
366
+ `;
536
367
  }
537
368
 
538
369
  function formatDate(dateStrOrDate) {
@@ -540,7 +371,6 @@ function formatDate(dateStrOrDate) {
540
371
  try {
541
372
  const date = new Date(dateStrOrDate);
542
373
  if (isNaN(date.getTime())) return "Invalid Date";
543
- // Using a more common and less verbose format
544
374
  return (
545
375
  date.toLocaleDateString(undefined, {
546
376
  year: "2-digit",
@@ -559,331 +389,260 @@ function generateTestHistoryChart(history) {
559
389
  if (!history || history.length === 0)
560
390
  return '<div class="no-data-chart">No data for chart</div>';
561
391
 
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
392
  const validHistory = history.filter(
582
393
  (h) => h && typeof h.duration === "number" && h.duration >= 0
583
394
  );
584
395
  if (validHistory.length === 0)
585
396
  return '<div class="no-data-chart">No valid data for chart</div>';
586
397
 
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");
398
+ const chartId = `testHistoryChart-${Date.now()}-${Math.random()
399
+ .toString(36)
400
+ .substring(2, 7)}`;
401
+
402
+ const seriesDataPoints = validHistory.map((run) => {
403
+ let color;
404
+ switch (String(run.status).toLowerCase()) {
405
+ case "passed":
406
+ color = "var(--success-color)";
407
+ break;
408
+ case "failed":
409
+ color = "var(--danger-color)";
410
+ break;
411
+ case "skipped":
412
+ color = "var(--warning-color)";
413
+ break;
414
+ default:
415
+ color = "var(--dark-gray-color)";
416
+ }
417
+ return {
418
+ y: run.duration,
419
+ marker: {
420
+ fillColor: color,
421
+ symbol: "circle",
422
+ radius: 3.5,
423
+ states: { hover: { radius: 5 } },
424
+ },
425
+ status: run.status,
426
+ runId: run.runId,
427
+ };
730
428
  });
731
429
 
732
- return body.html();
733
- }
430
+ // Assuming var(--accent-color) is Deep Purple #673ab7 -> RGB 103, 58, 183
431
+ const accentColorRGB = "103, 58, 183";
432
+
433
+ const optionsObjectString = `
434
+ {
435
+ chart: { type: 'area', height: 100, width: 320, backgroundColor: 'transparent', spacing: [10,10,15,35] },
436
+ title: { text: null },
437
+ xAxis: {
438
+ categories: ${JSON.stringify(
439
+ validHistory.map((_, i) => `R${i + 1}`)
440
+ )},
441
+ labels: { style: { fontSize: '10px', color: 'var(--text-color-secondary)' } }
442
+ },
443
+ yAxis: {
444
+ title: { text: null },
445
+ labels: {
446
+ formatter: function() { return formatDuration(this.value); },
447
+ style: { fontSize: '10px', color: 'var(--text-color-secondary)' },
448
+ align: 'left', x: -35, y: 3
449
+ },
450
+ min: 0,
451
+ gridLineWidth: 0,
452
+ tickAmount: 4
453
+ },
454
+ legend: { enabled: false },
455
+ plotOptions: {
456
+ area: {
457
+ lineWidth: 2,
458
+ lineColor: 'var(--accent-color)',
459
+ fillColor: {
460
+ linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
461
+ stops: [
462
+ [0, 'rgba(${accentColorRGB}, 0.4)'],
463
+ [1, 'rgba(${accentColorRGB}, 0)']
464
+ ]
465
+ },
466
+ marker: { enabled: true },
467
+ threshold: null
468
+ }
469
+ },
470
+ tooltip: {
471
+ useHTML: true,
472
+ backgroundColor: 'rgba(10,10,10,0.92)',
473
+ borderColor: 'rgba(10,10,10,0.92)',
474
+ style: { color: '#f5f5f5', padding: '8px' },
475
+ formatter: function() {
476
+ const pointData = this.point;
477
+ let statusBadgeHtml = '<span style="padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; background-color: ';
478
+ switch(String(pointData.status).toLowerCase()) {
479
+ case 'passed': statusBadgeHtml += 'var(--success-color)'; break;
480
+ case 'failed': statusBadgeHtml += 'var(--danger-color)'; break;
481
+ case 'skipped': statusBadgeHtml += 'var(--warning-color)'; break;
482
+ default: statusBadgeHtml += 'var(--dark-gray-color)';
483
+ }
484
+ statusBadgeHtml += ';">' + String(pointData.status).toUpperCase() + '</span>';
734
485
 
735
- function generatePieChartD3(data, chartWidth = 300, chartHeight = 300) {
736
- const { document } = new JSDOM().window;
737
- const body = d3.select(document.body);
486
+ return '<strong>Run ' + (pointData.runId || (this.point.index + 1)) + '</strong><br>' +
487
+ 'Status: ' + statusBadgeHtml + '<br>' +
488
+ 'Duration: ' + formatDuration(pointData.y);
489
+ }
490
+ },
491
+ series: [{
492
+ data: ${JSON.stringify(seriesDataPoints)},
493
+ showInLegend: false
494
+ }],
495
+ credits: { enabled: false }
496
+ }
497
+ `;
498
+ return `
499
+ <div id="${chartId}" style="width: 320px; height: 100px;"></div>
500
+ <script>
501
+ document.addEventListener('DOMContentLoaded', function() {
502
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
503
+ try {
504
+ const chartOptions = ${optionsObjectString};
505
+ Highcharts.chart('${chartId}', chartOptions);
506
+ } catch (e) {
507
+ console.error("Error rendering chart ${chartId}:", e);
508
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data-chart">Error rendering history chart.</div>';
509
+ }
510
+ } else {
511
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data-chart">Charting library not available.</div>';
512
+ }
513
+ });
514
+ </script>
515
+ `;
516
+ }
738
517
 
518
+ function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
739
519
  const total = data.reduce((sum, d) => sum + d.value, 0);
740
520
  if (total === 0) {
741
- return '<div class="no-data">No data for Test Distribution chart.</div>';
521
+ return '<div class="pie-chart-wrapper"><h3>Test Distribution</h3><div class="no-data">No data for Test Distribution chart.</div></div>';
742
522
  }
523
+ const passedEntry = data.find((d) => d.label === "Passed");
743
524
  const passedPercentage = Math.round(
744
- ((data.find((d) => d.label === "Passed")?.value || 0) / total) * 100
525
+ ((passedEntry ? passedEntry.value : 0) / total) * 100
745
526
  );
746
527
 
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
- });
528
+ const chartId = `pieChart-${Date.now()}-${Math.random()
529
+ .toString(36)
530
+ .substring(2, 7)}`;
828
531
 
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
- });
532
+ const seriesData = [
533
+ {
534
+ name: "Tests", // Changed from 'Test Distribution' for tooltip clarity
535
+ data: data
536
+ .filter((d) => d.value > 0)
537
+ .map((d) => {
538
+ let color;
539
+ switch (d.label) {
540
+ case "Passed":
541
+ color = "var(--success-color)";
542
+ break;
543
+ case "Failed":
544
+ color = "var(--danger-color)";
545
+ break;
546
+ case "Skipped":
547
+ color = "var(--warning-color)";
548
+ break;
549
+ default:
550
+ color = "#CCCCCC"; // A neutral default color
551
+ }
552
+ return { name: d.label, y: d.value, color: color };
553
+ }),
554
+ size: "100%",
555
+ innerSize: "55%",
556
+ dataLabels: { enabled: false },
557
+ showInLegend: true,
558
+ },
559
+ ];
864
560
 
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");
561
+ // Approximate font size for center text, can be adjusted or made dynamic with more client-side JS
562
+ const centerTitleFontSize =
563
+ Math.max(12, Math.min(chartWidth, chartHeight) / 12) + "px";
564
+ const centerSubtitleFontSize =
565
+ Math.max(10, Math.min(chartWidth, chartHeight) / 18) + "px";
566
+
567
+ const optionsObjectString = `
568
+ {
569
+ chart: {
570
+ type: 'pie',
571
+ width: ${chartWidth},
572
+ height: ${
573
+ chartHeight - 40
574
+ }, // Adjusted height to make space for legend if chartHeight is for the whole wrapper
575
+ backgroundColor: 'transparent',
576
+ plotShadow: false,
577
+ spacingBottom: 40 // Ensure space for legend
578
+ },
579
+ title: {
580
+ text: '${passedPercentage}%',
581
+ align: 'center',
582
+ verticalAlign: 'middle',
583
+ y: 5,
584
+ style: { fontSize: '${centerTitleFontSize}', fontWeight: 'bold', color: 'var(--primary-color)' }
585
+ },
586
+ subtitle: {
587
+ text: 'Passed',
588
+ align: 'center',
589
+ verticalAlign: 'middle',
590
+ y: 25,
591
+ style: { fontSize: '${centerSubtitleFontSize}', color: 'var(--text-color-secondary)' }
592
+ },
593
+ tooltip: {
594
+ pointFormat: '{series.name}: <b>{point.percentage:.1f}%</b> ({point.y})',
595
+ backgroundColor: 'rgba(10,10,10,0.92)',
596
+ borderColor: 'rgba(10,10,10,0.92)',
597
+ style: { color: '#f5f5f5' }
598
+ },
599
+ legend: {
600
+ layout: 'horizontal',
601
+ align: 'center',
602
+ verticalAlign: 'bottom',
603
+ itemStyle: { color: 'var(--text-color)', fontWeight: 'normal', fontSize: '12px' }
604
+ },
605
+ plotOptions: {
606
+ pie: {
607
+ allowPointSelect: true,
608
+ cursor: 'pointer',
609
+ borderWidth: 3,
610
+ borderColor: 'var(--card-background-color)', // Match D3 style
611
+ states: {
612
+ hover: {
613
+ // Using default Highcharts halo which is generally good
614
+ }
615
+ }
616
+ }
617
+ },
618
+ series: ${JSON.stringify(seriesData)},
619
+ credits: { enabled: false }
620
+ }
621
+ `;
881
622
 
882
623
  return `
883
- <div class="pie-chart-wrapper">
884
- <h3>Test Distribution</h3>
885
- ${body.html()}
886
- </div>`;
624
+ <div class="pie-chart-wrapper" style="align-items: center">
625
+ <div style="display: flex; align-items: start; width: 100%;"><h3>Test Distribution</h3></div>
626
+ <div id="${chartId}" style="width: ${chartWidth}px; height: ${
627
+ chartHeight - 40
628
+ }px;"></div>
629
+ <script>
630
+ document.addEventListener('DOMContentLoaded', function() {
631
+ if (typeof Highcharts !== 'undefined') {
632
+ try {
633
+ const chartOptions = ${optionsObjectString};
634
+ Highcharts.chart('${chartId}', chartOptions);
635
+ } catch (e) {
636
+ console.error("Error rendering chart ${chartId}:", e);
637
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering pie chart.</div>';
638
+ }
639
+ } else {
640
+ document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
641
+ }
642
+ });
643
+ </script>
644
+ </div>
645
+ `;
887
646
  }
888
647
 
889
648
  function generateTestHistoryContent(trendData) {
@@ -895,7 +654,7 @@ function generateTestHistoryContent(trendData) {
895
654
  return '<div class="no-data">No historical test data available.</div>';
896
655
  }
897
656
 
898
- const allTestNamesAndPaths = new Map(); // Store {path: name, title: title}
657
+ const allTestNamesAndPaths = new Map();
899
658
  Object.values(trendData.testRuns).forEach((run) => {
900
659
  if (Array.isArray(run)) {
901
660
  run.forEach((test) => {
@@ -941,14 +700,15 @@ function generateTestHistoryContent(trendData) {
941
700
  return `
942
701
  <div class="test-history-container">
943
702
  <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>
703
+ <input type="text" id="history-filter-name" placeholder="Search by test title..." style="border-color: black; border-style: outset;">
704
+ <select id="history-filter-status">
705
+ <option value="">All Statuses</option>
706
+ <option value="passed">Passed</option>
707
+ <option value="failed">Failed</option>
708
+ <option value="skipped">Skipped</option>
709
+ </select>
710
+ <button id="clear-history-filters" class="clear-filters-btn">Clear Filters</button>
711
+ </div>
952
712
 
953
713
  <div class="test-history-grid">
954
714
  ${testHistory
@@ -957,7 +717,6 @@ function generateTestHistoryContent(trendData) {
957
717
  test.history.length > 0
958
718
  ? test.history[test.history.length - 1]
959
719
  : { status: "unknown" };
960
- // For data-test-name, use the title for filtering as per input placeholder
961
720
  return `
962
721
  <div class="test-history-card" data-test-name="${sanitizeHTML(
963
722
  test.testTitle.toLowerCase()
@@ -967,7 +726,7 @@ function generateTestHistoryContent(trendData) {
967
726
  sanitizeHTML(test.testTitle)
968
727
  )}</p>
969
728
  <span class="status-badge ${getStatusClass(latestRun.status)}">
970
- ${latestRun.status.toUpperCase()}
729
+ ${String(latestRun.status).toUpperCase()}
971
730
  </span>
972
731
  </div>
973
732
  <div class="test-history-trend">
@@ -988,7 +747,7 @@ function generateTestHistoryContent(trendData) {
988
747
  <td>${run.runId}</td>
989
748
  <td><span class="status-badge-small ${getStatusClass(
990
749
  run.status
991
- )}">${run.status.toUpperCase()}</span></td>
750
+ )}">${String(run.status).toUpperCase()}</span></td>
992
751
  <td>${formatDuration(run.duration)}</td>
993
752
  <td>${formatDate(run.timestamp)}</td>
994
753
  </tr>`
@@ -1039,19 +798,15 @@ function getSuitesData(results) {
1039
798
  results.forEach((test) => {
1040
799
  const browser = test.browser || "unknown";
1041
800
  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
801
  let suiteNameCandidate = "Default Suite";
1044
802
  if (suiteParts.length > 2) {
1045
- // e.g. file > suite > test
1046
803
  suiteNameCandidate = suiteParts[1];
1047
804
  } else if (suiteParts.length > 1) {
1048
- // e.g. file > test
1049
805
  suiteNameCandidate = suiteParts[0]
1050
806
  .split(path.sep)
1051
807
  .pop()
1052
808
  .replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
1053
809
  } else {
1054
- // Just file name or malformed
1055
810
  suiteNameCandidate = test.name
1056
811
  .split(path.sep)
1057
812
  .pop()
@@ -1158,7 +913,7 @@ function generateHTML(reportData, trendData = null) {
1158
913
  timestamp: new Date().toISOString(),
1159
914
  };
1160
915
 
1161
- const totalTestsOr1 = runSummary.totalTests || 1;
916
+ const totalTestsOr1 = runSummary.totalTests || 1; // Avoid division by zero
1162
917
  const passPercentage = Math.round((runSummary.passed / totalTestsOr1) * 100);
1163
918
  const failPercentage = Math.round((runSummary.failed / totalTestsOr1) * 100);
1164
919
  const skipPercentage = Math.round(
@@ -1169,12 +924,8 @@ function generateHTML(reportData, trendData = null) {
1169
924
  ? formatDuration(runSummary.duration / runSummary.totalTests)
1170
925
  : "0.0s";
1171
926
 
1172
- // Inside generate-static-report.mjs
1173
-
1174
927
  function generateTestCasesHTML() {
1175
- // Make sure this is within the scope where 'results' is defined
1176
928
  if (!results || results.length === 0) {
1177
- // Assuming 'results' is accessible here
1178
929
  return '<div class="no-tests">No test results found in this run.</div>';
1179
930
  }
1180
931
 
@@ -1220,7 +971,6 @@ function generateHTML(reportData, trendData = null) {
1220
971
  step.errorMessage
1221
972
  ? `
1222
973
  <div class="step-error">
1223
- <strong>Error:</strong> ${sanitizeHTML(step.errorMessage)}
1224
974
  ${
1225
975
  step.stackTrace
1226
976
  ? `<pre class="stack-trace">${sanitizeHTML(
@@ -1275,17 +1025,16 @@ function generateHTML(reportData, trendData = null) {
1275
1025
  <div class="test-case-content" style="display: none;">
1276
1026
  <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
1277
1027
  ${
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>`
1028
+ test.error
1029
+ ? `<div class="test-error-summary">
1030
+ ${formatPlaywrightError(test.error)}
1031
+ </div>`
1282
1032
  : ""
1283
1033
  }
1284
1034
 
1285
1035
  <h4>Steps</h4>
1286
1036
  <div class="steps-list">${generateStepsHTML(test.steps)}</div>
1287
1037
 
1288
- ${/* NEW: stdout and stderr sections START */ ""}
1289
1038
  ${
1290
1039
  test.stdout && test.stdout.length > 0
1291
1040
  ? `
@@ -1308,77 +1057,132 @@ function generateHTML(reportData, trendData = null) {
1308
1057
  </div>`
1309
1058
  : ""
1310
1059
  }
1311
- ${/* NEW: stdout and stderr sections END */ ""}
1312
1060
 
1313
- ${
1314
- test.screenshots && test.screenshots.length > 0
1315
- ? `
1061
+ ${
1062
+ test.screenshots && test.screenshots.length > 0
1063
+ ? `
1316
1064
  <div class="attachments-section">
1317
1065
  <h4>Screenshots</h4>
1318
1066
  <div class="attachments-grid">
1319
1067
  ${test.screenshots
1320
- .map((screenshot) => {
1321
- // Ensure screenshot.path and screenshot.name are accessed correctly
1322
- const imgSrc = sanitizeHTML(screenshot.path || "");
1323
- const screenshotName = sanitizeHTML(
1324
- screenshot.name || "Screenshot"
1325
- );
1326
- return imgSrc
1327
- ? `
1328
- <div class="attachment-item screenshot-item">
1329
- <a href="${imgSrc}" target="_blank" title="Click to view ${screenshotName} (full size)">
1330
- <img src="${imgSrc}" alt="${screenshotName}" loading="lazy">
1331
- </a>
1332
- <div class="attachment-caption">${screenshotName}</div>
1333
- </div>`
1334
- : "";
1335
- })
1068
+ .map(
1069
+ (screenshot) => `
1070
+ <div class="attachment-item">
1071
+ <img src="${screenshot}" alt="Screenshot">
1072
+ <div class="attachment-info">
1073
+ <div class="trace-actions">
1074
+ <a href="${screenshot}" target="_blank">View Full Size</a></div>
1075
+ </div>
1076
+ </div>
1077
+ `
1078
+ )
1336
1079
  .join("")}
1337
1080
  </div>
1338
- </div>`
1339
- : ""
1340
- }
1081
+ </div>
1082
+ `
1083
+ : ""
1084
+ }
1341
1085
 
1342
1086
  ${
1343
- test.videos && test.videos.length > 0
1087
+ test.videoPath
1344
1088
  ? `
1345
1089
  <div class="attachments-section">
1346
1090
  <h4>Videos</h4>
1347
- ${test.videos
1348
- .map(
1349
- (video) => `
1350
- <div class="video-item">
1351
- <a href="${sanitizeHTML(
1352
- video.path
1353
- )}" target="_blank">View Video: ${sanitizeHTML(
1354
- video.name || path.basename(video.path) // path.basename might not be available if path module not passed/scoped
1355
- )}</a>
1356
- </div>`
1357
- )
1358
- .join("")}
1359
- </div>`
1091
+ <div class="attachments-grid">
1092
+ ${(() => {
1093
+ // Handle both string and array cases
1094
+ const videos = Array.isArray(test.videoPath)
1095
+ ? test.videoPath
1096
+ : [test.videoPath];
1097
+ const mimeTypes = {
1098
+ mp4: "video/mp4",
1099
+ webm: "video/webm",
1100
+ ogg: "video/ogg",
1101
+ mov: "video/quicktime",
1102
+ avi: "video/x-msvideo",
1103
+ };
1104
+
1105
+ return videos
1106
+ .map((video, index) => {
1107
+ const videoUrl =
1108
+ typeof video === "object" ? video.url || "" : video;
1109
+ const videoName =
1110
+ typeof video === "object"
1111
+ ? video.name || `Video ${index + 1}`
1112
+ : `Video ${index + 1}`;
1113
+ const fileExtension = videoUrl
1114
+ .split(".")
1115
+ .pop()
1116
+ .toLowerCase();
1117
+ const mimeType = mimeTypes[fileExtension] || "video/mp4";
1118
+
1119
+ return `
1120
+ <div class="attachment-item">
1121
+ <video controls width="100%" height="auto" title="${videoName}">
1122
+ <source src="${videoUrl}" type="${mimeType}">
1123
+ Your browser does not support the video tag.
1124
+ </video>
1125
+ <div class="attachment-info">
1126
+ <div class="trace-actions">
1127
+ <a href="${videoUrl}" target="_blank" download="${videoName}.${fileExtension}">
1128
+ Download
1129
+ </a>
1130
+ </div>
1131
+ </div>
1132
+ </div>
1133
+ `;
1134
+ })
1135
+ .join("");
1136
+ })()}
1137
+ </div>
1138
+ </div>
1139
+ `
1360
1140
  : ""
1361
1141
  }
1362
-
1142
+
1363
1143
  ${
1364
- test.traces && test.traces.length > 0
1144
+ test.tracePath
1365
1145
  ? `
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>`
1146
+ <div class="attachments-section">
1147
+ <h4>Trace Files</h4>
1148
+ <div class="attachments-grid">
1149
+ ${(() => {
1150
+ // Handle both string and array cases
1151
+ const traces = Array.isArray(test.tracePath)
1152
+ ? test.tracePath
1153
+ : [test.tracePath];
1154
+
1155
+ return traces
1156
+ .map((trace, index) => {
1157
+ const traceUrl =
1158
+ typeof trace === "object" ? trace.url || "" : trace;
1159
+ const traceName =
1160
+ typeof trace === "object"
1161
+ ? trace.name || `Trace ${index + 1}`
1162
+ : `Trace ${index + 1}`;
1163
+ const traceFileName = traceUrl.split("/").pop();
1164
+
1165
+ return `
1166
+ <div class="attachment-item">
1167
+ <div class="trace-preview">
1168
+ <span class="trace-icon">📄</span>
1169
+ <span class="trace-name">${traceName}</span>
1170
+ </div>
1171
+ <div class="attachment-info">
1172
+ <div class="trace-actions">
1173
+ <a href="${traceUrl}" target="_blank" download="${traceFileName}" class="download-trace">
1174
+ Download
1175
+ </a>
1176
+ </div>
1177
+ </div>
1178
+ </div>
1179
+ `;
1180
+ })
1181
+ .join("");
1182
+ })()}
1183
+ </div>
1184
+ </div>
1185
+ `
1382
1186
  : ""
1383
1187
  }
1384
1188
 
@@ -1403,6 +1207,7 @@ function generateHTML(reportData, trendData = null) {
1403
1207
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1404
1208
  <link rel="icon" type="image/png" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1405
1209
  <link rel="apple-touch-icon" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1210
+ <script src="https://code.highcharts.com/highcharts.js"></script>
1406
1211
  <title>Playwright Pulse Report</title>
1407
1212
  <style>
1408
1213
  :root {
@@ -1420,27 +1225,34 @@ function generateHTML(reportData, trendData = null) {
1420
1225
  --text-color: #333;
1421
1226
  --text-color-secondary: #555;
1422
1227
  --border-color: #ddd;
1423
- --background-color: #f8f9fa; /* Even lighter gray */
1228
+ --background-color: #f8f9fa;
1424
1229
  --card-background-color: #fff;
1425
1230
  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
1426
1231
  --border-radius: 8px;
1427
- --box-shadow: 0 5px 15px rgba(0,0,0,0.08); /* Softer shadow */
1232
+ --box-shadow: 0 5px 15px rgba(0,0,0,0.08);
1428
1233
  --box-shadow-light: 0 3px 8px rgba(0,0,0,0.05);
1429
1234
  --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.07);
1430
1235
  }
1236
+
1237
+ /* General Highcharts styling */
1238
+ .highcharts-background { fill: transparent; }
1239
+ .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
1240
+ .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
1241
+ .highcharts-axis-title { fill: var(--text-color) !important; }
1242
+ .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; }
1431
1243
 
1432
1244
  body {
1433
1245
  font-family: var(--font-family);
1434
1246
  margin: 0;
1435
1247
  background-color: var(--background-color);
1436
1248
  color: var(--text-color);
1437
- line-height: 1.65; /* Increased line height */
1249
+ line-height: 1.65;
1438
1250
  font-size: 16px;
1439
1251
  }
1440
1252
 
1441
1253
  .container {
1442
1254
  max-width: 1600px;
1443
- padding: 30px; /* Increased padding */
1255
+ padding: 30px;
1444
1256
  border-radius: var(--border-radius);
1445
1257
  box-shadow: var(--box-shadow);
1446
1258
  background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec);
@@ -1492,27 +1304,27 @@ function generateHTML(reportData, trendData = null) {
1492
1304
  .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
1493
1305
 
1494
1306
  .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 */
1307
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
1308
+ gap: 28px; align-items: stretch;
1497
1309
  }
1498
1310
  .pie-chart-wrapper, .suites-widget, .trend-chart {
1499
- background-color: var(--card-background-color); padding: 28px; /* Increased padding */
1311
+ background-color: var(--card-background-color); padding: 28px;
1500
1312
  border-radius: var(--border-radius); box-shadow: var(--box-shadow-light);
1501
- display: flex; flex-direction: column; /* For internal alignment */
1313
+ display: flex; flex-direction: column;
1502
1314
  }
1503
- .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3, .main-chart-title {
1315
+
1316
+ .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3 {
1504
1317
  text-align: center; margin-top: 0; margin-bottom: 25px;
1505
1318
  font-size: 1.25em; font-weight: 600; color: var(--text-color);
1506
1319
  }
1507
- .pie-chart-wrapper svg, .trend-chart-container svg { display: block; margin: 0 auto; max-width: 100%; height: auto; flex-grow: 1;}
1320
+ .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { /* For Highcharts containers */
1321
+ flex-grow: 1;
1322
+ min-height: 250px; /* Ensure charts have some min height */
1323
+ }
1508
1324
 
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;
1325
+ .chart-tooltip { /* This class was for D3, Highcharts has its own tooltip styling via JS/SVG */
1326
+ /* Basic styling for Highcharts HTML tooltips can be done via .highcharts-tooltip span */
1514
1327
  }
1515
- .chart-tooltip strong { color: #fff; font-weight: 600;}
1516
1328
  .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
1517
1329
  .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
1518
1330
  .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
@@ -1564,7 +1376,7 @@ function generateHTML(reportData, trendData = null) {
1564
1376
  border-bottom: 1px solid transparent;
1565
1377
  transition: background-color 0.2s ease;
1566
1378
  }
1567
- .test-case-header:hover { background-color: #f4f6f8; } /* Lighter hover */
1379
+ .test-case-header:hover { background-color: #f4f6f8; }
1568
1380
  .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: #f9fafb; }
1569
1381
 
1570
1382
  .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
@@ -1634,24 +1446,10 @@ function generateHTML(reportData, trendData = null) {
1634
1446
  .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
1447
 
1636
1448
  .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;}
1449
+ /* Removed D3 specific .chart-axis, .main-chart-title, .chart-line.* rules */
1450
+ /* Highcharts styles its elements with classes like .highcharts-axis, .highcharts-title etc. */
1647
1451
 
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;}
1452
+ .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
1453
  .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
1656
1454
  .test-history-card {
1657
1455
  background: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius);
@@ -1661,8 +1459,10 @@ function generateHTML(reportData, trendData = null) {
1661
1459
  .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1662
1460
  .test-history-header p { font-weight: 500 }
1663
1461
  .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;}
1462
+ .test-history-trend div[id^="testHistoryChart-"] { /* Highcharts container for history */
1463
+ display: block; margin: 0 auto; max-width:100%; height: 100px; width: 320px; /* Match JS config */
1464
+ }
1465
+ /* .test-history-trend .small-axis text {font-size: 11px;} Removed D3 specific */
1666
1466
  .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
1667
1467
  .test-history-details-collapsible summary:hover {text-decoration: underline;}
1668
1468
  .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
@@ -1677,7 +1477,6 @@ function generateHTML(reportData, trendData = null) {
1677
1477
  .status-badge-small.status-skipped { background-color: var(--warning-color); }
1678
1478
  .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
1679
1479
 
1680
-
1681
1480
  .no-data, .no-tests, .no-steps, .no-data-chart {
1682
1481
  padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em;
1683
1482
  background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0;
@@ -1689,19 +1488,79 @@ function generateHTML(reportData, trendData = null) {
1689
1488
  #test-ai p {margin-bottom: 18px; font-size: 1em; color: var(--text-color-secondary);}
1690
1489
  pre .stdout-log { background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1691
1490
  pre .stderr-log { background-color: #2d2d2d; color: indianred; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1692
- /* Responsive Enhancements */
1491
+
1492
+ .trace-preview {
1493
+ padding: 1rem;
1494
+ text-align: center;
1495
+ background: #f5f5f5;
1496
+ border-bottom: 1px solid #e1e1e1;
1497
+ }
1498
+
1499
+ .trace-icon {
1500
+ font-size: 2rem;
1501
+ display: block;
1502
+ margin-bottom: 0.5rem;
1503
+ }
1504
+
1505
+ .trace-name {
1506
+ word-break: break-word;
1507
+ font-size: 0.9rem;
1508
+ }
1509
+
1510
+ .trace-actions {
1511
+ display: flex;
1512
+ gap: 0.5rem;
1513
+ }
1514
+
1515
+ .trace-actions a {
1516
+ flex: 1;
1517
+ text-align: center;
1518
+ padding: 0.25rem 0.5rem;
1519
+ font-size: 0.85rem;
1520
+ border-radius: 4px;
1521
+ text-decoration: none;
1522
+ }
1523
+
1524
+ .view-trace {
1525
+ background: #3182ce;
1526
+ color: white;
1527
+ }
1528
+
1529
+ .view-trace:hover {
1530
+ background: #2c5282;
1531
+ }
1532
+
1533
+ .download-trace {
1534
+ background: #e2e8f0;
1535
+ color: #2d3748;
1536
+ }
1537
+
1538
+ .download-trace:hover {
1539
+ background: #cbd5e0;
1540
+ }
1541
+
1542
+ .filters button.clear-filters-btn {
1543
+ background-color: var(--medium-gray-color); /* Or any other suitable color */
1544
+ color: var(--text-color);
1545
+ /* Add other styling as per your .filters button style if needed */
1546
+ }
1547
+
1548
+ .filters button.clear-filters-btn:hover {
1549
+ background-color: var(--dark-gray-color); /* Darker on hover */
1550
+ color: #fff;
1551
+ }
1693
1552
  @media (max-width: 1200px) {
1694
- .trend-charts-row { grid-template-columns: 1fr; } /* Stack trend charts earlier */
1553
+ .trend-charts-row { grid-template-columns: 1fr; }
1695
1554
  }
1696
1555
  @media (max-width: 992px) {
1697
1556
  .dashboard-bottom-row { grid-template-columns: 1fr; }
1698
- .pie-chart-wrapper svg { max-width: 350px; }
1557
+ .pie-chart-wrapper div[id^="pieChart-"] { max-width: 350px; margin: 0 auto; }
1699
1558
  .filters input { min-width: 180px; }
1700
1559
  .filters select { min-width: 150px; }
1701
1560
  }
1702
1561
  @media (max-width: 768px) {
1703
1562
  body { font-size: 15px; }
1704
- .container { margin: 10px; padding: 20px; } /* Adjusted padding */
1563
+ .container { margin: 10px; padding: 20px; }
1705
1564
  .header { flex-direction: column; align-items: flex-start; gap: 15px; }
1706
1565
  .header h1 { font-size: 1.6em; }
1707
1566
  .run-info { text-align: left; font-size:0.9em; }
@@ -1718,9 +1577,7 @@ function generateHTML(reportData, trendData = null) {
1718
1577
  .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;}
1719
1578
  .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;}
1720
1579
  .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
-
1580
+ .pie-chart-wrapper {min-height: auto;}
1724
1581
  }
1725
1582
  @media (max-width: 480px) {
1726
1583
  body {font-size: 14px;}
@@ -1730,14 +1587,10 @@ function generateHTML(reportData, trendData = null) {
1730
1587
  .tab-button {padding: 10px 15px; font-size: 1em;}
1731
1588
  .summary-card .value {font-size: 1.8em;}
1732
1589
  .attachments-grid {grid-template-columns: 1fr;}
1733
- .step-item {padding-left: calc(var(--depth, 0) * 18px);} /* Reduced indent */
1590
+ .step-item {padding-left: calc(var(--depth, 0) * 18px);}
1734
1591
  .test-case-content, .step-details {padding: 15px;}
1735
1592
  .trend-charts-row {gap: 20px;}
1736
1593
  .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
1594
  }
1742
1595
  </style>
1743
1596
  </head>
@@ -1796,14 +1649,15 @@ function generateHTML(reportData, trendData = null) {
1796
1649
  </div>
1797
1650
  </div>
1798
1651
  <div class="dashboard-bottom-row">
1799
- ${generatePieChartD3(
1652
+ ${generatePieChart(
1653
+ // Changed from generatePieChartD3
1800
1654
  [
1801
1655
  { label: "Passed", value: runSummary.passed },
1802
1656
  { label: "Failed", value: runSummary.failed },
1803
1657
  { label: "Skipped", value: runSummary.skipped || 0 },
1804
1658
  ],
1805
- 400,
1806
- 350
1659
+ 400, // Default width
1660
+ 390 // Default height (adjusted for legend + title)
1807
1661
  )}
1808
1662
  ${generateSuitesWidget(suitesData)}
1809
1663
  </div>
@@ -1811,31 +1665,31 @@ function generateHTML(reportData, trendData = null) {
1811
1665
 
1812
1666
  <div id="test-runs" class="tab-content">
1813
1667
  <div class="filters">
1814
- <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>
1668
+ <input type="text" id="filter-name" placeholder="Filter by test name/path..." style="border-color: black; border-style: outset;">
1669
+ <select id="filter-status">
1670
+ <option value="">All Statuses</option>
1671
+ <option value="passed">Passed</option>
1672
+ <option value="failed">Failed</option>
1673
+ <option value="skipped">Skipped</option>
1674
+ </select>
1675
+ <select id="filter-browser">
1676
+ <option value="">All Browsers</option>
1677
+ {/* Dynamically generated options will be here */}
1678
+ ${Array.from(
1679
+ new Set((results || []).map((test) => test.browser || "unknown"))
1680
+ )
1681
+ .map(
1682
+ (browser) =>
1683
+ `<option value="${sanitizeHTML(browser)}">${sanitizeHTML(
1684
+ browser
1685
+ )}</option>`
1686
+ )
1687
+ .join("")}
1688
+ </select>
1689
+ <button id="expand-all-tests">Expand All</button>
1690
+ <button id="collapse-all-tests">Collapse All</button>
1691
+ <button id="clear-run-summary-filters" class="clear-filters-btn">Clear Filters</button>
1692
+ </div>
1839
1693
  <div class="test-cases-list">
1840
1694
  ${generateTestCasesHTML()}
1841
1695
  </div>
@@ -1925,6 +1779,14 @@ function generateHTML(reportData, trendData = null) {
1925
1779
 
1926
1780
 
1927
1781
  <script>
1782
+ // Ensure formatDuration is globally available
1783
+ if (typeof formatDuration === 'undefined') {
1784
+ function formatDuration(ms) {
1785
+ if (ms === undefined || ms === null || ms < 0) return "0.0s";
1786
+ return (ms / 1000).toFixed(1) + "s";
1787
+ }
1788
+ }
1789
+
1928
1790
  function initializeReportInteractivity() {
1929
1791
  const tabButtons = document.querySelectorAll('.tab-button');
1930
1792
  const tabContents = document.querySelectorAll('.tab-content');
@@ -1939,9 +1801,11 @@ function generateHTML(reportData, trendData = null) {
1939
1801
  });
1940
1802
  });
1941
1803
 
1804
+ // --- Test Run Summary Filters ---
1942
1805
  const nameFilter = document.getElementById('filter-name');
1943
1806
  const statusFilter = document.getElementById('filter-status');
1944
1807
  const browserFilter = document.getElementById('filter-browser');
1808
+ const clearRunSummaryFiltersBtn = document.getElementById('clear-run-summary-filters'); // Get the new button
1945
1809
 
1946
1810
  function filterTestCases() {
1947
1811
  const nameValue = nameFilter ? nameFilter.value.toLowerCase() : "";
@@ -1950,7 +1814,6 @@ function generateHTML(reportData, trendData = null) {
1950
1814
 
1951
1815
  document.querySelectorAll('#test-runs .test-case').forEach(testCaseElement => {
1952
1816
  const titleElement = testCaseElement.querySelector('.test-case-title');
1953
- // Use the 'title' attribute of .test-case-title for full path filtering
1954
1817
  const fullTestName = titleElement ? titleElement.getAttribute('title').toLowerCase() : "";
1955
1818
  const status = testCaseElement.getAttribute('data-status');
1956
1819
  const browser = testCaseElement.getAttribute('data-browser');
@@ -1966,15 +1829,27 @@ function generateHTML(reportData, trendData = null) {
1966
1829
  if(statusFilter) statusFilter.addEventListener('change', filterTestCases);
1967
1830
  if(browserFilter) browserFilter.addEventListener('change', filterTestCases);
1968
1831
 
1832
+ // Event listener for clearing Test Run Summary filters
1833
+ if (clearRunSummaryFiltersBtn) {
1834
+ clearRunSummaryFiltersBtn.addEventListener('click', () => {
1835
+ if (nameFilter) nameFilter.value = '';
1836
+ if (statusFilter) statusFilter.value = '';
1837
+ if (browserFilter) browserFilter.value = '';
1838
+ filterTestCases(); // Re-apply filters (which will show all)
1839
+ });
1840
+ }
1841
+
1842
+ // --- Test History Filters ---
1969
1843
  const historyNameFilter = document.getElementById('history-filter-name');
1970
1844
  const historyStatusFilter = document.getElementById('history-filter-status');
1845
+ const clearHistoryFiltersBtn = document.getElementById('clear-history-filters'); // Get the new button
1846
+
1971
1847
 
1972
1848
  function filterTestHistoryCards() {
1973
1849
  const nameValue = historyNameFilter ? historyNameFilter.value.toLowerCase() : "";
1974
1850
  const statusValue = historyStatusFilter ? historyStatusFilter.value : "";
1975
1851
 
1976
1852
  document.querySelectorAll('.test-history-card').forEach(card => {
1977
- // data-test-name now holds the test title (last part of full name)
1978
1853
  const testTitle = card.getAttribute('data-test-name').toLowerCase();
1979
1854
  const latestStatus = card.getAttribute('data-latest-status');
1980
1855
 
@@ -1987,15 +1862,22 @@ function generateHTML(reportData, trendData = null) {
1987
1862
  if(historyNameFilter) historyNameFilter.addEventListener('input', filterTestHistoryCards);
1988
1863
  if(historyStatusFilter) historyStatusFilter.addEventListener('change', filterTestHistoryCards);
1989
1864
 
1865
+ // Event listener for clearing Test History filters
1866
+ if (clearHistoryFiltersBtn) {
1867
+ clearHistoryFiltersBtn.addEventListener('click', () => {
1868
+ if (historyNameFilter) historyNameFilter.value = '';
1869
+ if (historyStatusFilter) historyStatusFilter.value = '';
1870
+ filterTestHistoryCards(); // Re-apply filters (which will show all)
1871
+ });
1872
+ }
1873
+
1874
+ // --- Expand/Collapse and Toggle Details Logic (remains the same) ---
1990
1875
  function toggleElementDetails(headerElement, contentSelector) {
1991
1876
  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
1877
  if (headerElement.classList.contains('test-case-header')) {
1995
1878
  contentElement = headerElement.parentElement.querySelector('.test-case-content');
1996
1879
  } else if (headerElement.classList.contains('step-header')) {
1997
1880
  contentElement = headerElement.nextElementSibling;
1998
- // Verify it's the correct details div
1999
1881
  if (!contentElement || !contentElement.matches(contentSelector || '.step-details')) {
2000
1882
  contentElement = null;
2001
1883
  }
@@ -2029,20 +1911,17 @@ function generateHTML(reportData, trendData = null) {
2029
1911
  if (collapseAllBtn) collapseAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('none', 'false'));
2030
1912
  }
2031
1913
  document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
2032
- </script>
1914
+ </script>
2033
1915
  </body>
2034
1916
  </html>
2035
1917
  `;
2036
1918
  }
2037
1919
 
2038
- // Add this helper function somewhere in generate-static-report.mjs,
2039
- // possibly before your main() function.
2040
-
2041
1920
  async function runScript(scriptPath) {
2042
1921
  return new Promise((resolve, reject) => {
2043
1922
  console.log(chalk.blue(`Executing script: ${scriptPath}...`));
2044
1923
  const process = fork(scriptPath, [], {
2045
- stdio: "inherit", // This will pipe the child process's stdio to the parent
1924
+ stdio: "inherit",
2046
1925
  });
2047
1926
 
2048
1927
  process.on("error", (err) => {
@@ -2064,195 +1943,203 @@ async function runScript(scriptPath) {
2064
1943
  }
2065
1944
 
2066
1945
  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(
1946
+ const __filename = fileURLToPath(import.meta.url);
1947
+ const __dirname = path.dirname(__filename);
1948
+
1949
+ // Script to archive current run to JSON history (this is your modified "generate-trend.mjs")
1950
+ const archiveRunScriptPath = path.resolve(
2070
1951
  __dirname,
2071
- "generate-trend-excel.mjs"
2072
- ); // generate-trend-excel.mjs is in the SAME directory as generate-static-report.mjs
1952
+ "generate-trend.mjs" // Keeping the filename as per your request
1953
+ );
1954
+
2073
1955
  const outputDir = path.resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
2074
- const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE);
1956
+ const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE); // Current run's main JSON
2075
1957
  const reportHtmlPath = path.resolve(outputDir, DEFAULT_HTML_FILE);
2076
- const trendDataPath = path.resolve(outputDir, "trend.xls");
1958
+
1959
+ const historyDir = path.join(outputDir, "history"); // Directory for historical JSON files
1960
+ const HISTORY_FILE_PREFIX = "trend-"; // Match prefix used in archiving script
1961
+ const MAX_HISTORY_FILES_TO_LOAD_FOR_REPORT = 15; // How many historical runs to show in the report
2077
1962
 
2078
1963
  console.log(chalk.blue(`Starting static HTML report generation...`));
2079
1964
  console.log(chalk.blue(`Output directory set to: ${outputDir}`));
2080
1965
 
2081
- // --- Step 1: Ensure Excel trend data is generated/updated FIRST ---
1966
+ // Step 1: Ensure current run data is archived to the history folder
2082
1967
  try {
2083
- await runScript(trendExcelScriptPath);
2084
- console.log(chalk.green("Excel trend generation completed."));
1968
+ await runScript(archiveRunScriptPath); // This script now handles JSON history
1969
+ console.log(
1970
+ chalk.green("Current run data archiving to history completed.")
1971
+ );
2085
1972
  } catch (error) {
2086
1973
  console.error(
2087
1974
  chalk.red(
2088
- "Failed to generate/update Excel trend data. HTML report might use stale or no trend data."
1975
+ "Failed to archive current run data. Report might use stale or incomplete historical trends."
2089
1976
  ),
2090
1977
  error
2091
1978
  );
1979
+ // You might decide to proceed or exit depending on the importance of historical data
2092
1980
  }
2093
1981
 
2094
- let reportData;
1982
+ // Step 2: Load current run's data (for non-trend sections of the report)
1983
+ let currentRunReportData; // Data for the run being reported
2095
1984
  try {
2096
1985
  const jsonData = await fs.readFile(reportJsonPath, "utf-8");
2097
- reportData = JSON.parse(jsonData);
2098
- if (!reportData || typeof reportData !== "object" || !reportData.results) {
1986
+ currentRunReportData = JSON.parse(jsonData);
1987
+ if (
1988
+ !currentRunReportData ||
1989
+ typeof currentRunReportData !== "object" ||
1990
+ !currentRunReportData.results
1991
+ ) {
2099
1992
  throw new Error(
2100
1993
  "Invalid report JSON structure. 'results' field is missing or invalid."
2101
1994
  );
2102
1995
  }
2103
- if (!Array.isArray(reportData.results)) {
2104
- reportData.results = [];
1996
+ if (!Array.isArray(currentRunReportData.results)) {
1997
+ currentRunReportData.results = [];
2105
1998
  console.warn(
2106
1999
  chalk.yellow(
2107
- "Warning: 'results' field in JSON was not an array. Treated as empty."
2000
+ "Warning: 'results' field in current run JSON was not an array. Treated as empty."
2108
2001
  )
2109
2002
  );
2110
2003
  }
2111
2004
  } catch (error) {
2112
2005
  console.error(
2113
- chalk.red(`Error reading or parsing main report JSON: ${error.message}`)
2006
+ chalk.red(
2007
+ `Critical Error: Could not read or parse main report JSON at ${reportJsonPath}: ${error.message}`
2008
+ )
2114
2009
  );
2115
- process.exit(1);
2010
+ process.exit(1); // Exit if the main report for the current run is missing/invalid
2116
2011
  }
2117
2012
 
2118
- let trendData = { overall: [], testRuns: {} };
2013
+ // Step 3: Load historical data for trends
2014
+ let historicalRuns = []; // Array of past PlaywrightPulseReport objects
2119
2015
  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
- }
2016
+ await fs.access(historyDir); // Check if history directory exists
2017
+ const allHistoryFiles = await fs.readdir(historyDir);
2158
2018
 
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
- }
2019
+ const jsonHistoryFiles = allHistoryFiles
2020
+ .filter(
2021
+ (file) => file.startsWith(HISTORY_FILE_PREFIX) && file.endsWith(".json")
2022
+ )
2023
+ .map((file) => {
2024
+ const timestampPart = file
2025
+ .replace(HISTORY_FILE_PREFIX, "")
2026
+ .replace(".json", "");
2027
+ return {
2028
+ name: file,
2029
+ path: path.join(historyDir, file),
2030
+ timestamp: parseInt(timestampPart, 10),
2031
+ };
2032
+ })
2033
+ .filter((file) => !isNaN(file.timestamp))
2034
+ .sort((a, b) => b.timestamp - a.timestamp); // Sort newest first to easily pick the latest N
2171
2035
 
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
- });
2036
+ const filesToLoadForTrend = jsonHistoryFiles.slice(
2037
+ 0,
2038
+ MAX_HISTORY_FILES_TO_LOAD_FOR_REPORT
2039
+ );
2040
+
2041
+ for (const fileMeta of filesToLoadForTrend) {
2042
+ try {
2043
+ const fileContent = await fs.readFile(fileMeta.path, "utf-8");
2044
+ const runJsonData = JSON.parse(fileContent); // Each file IS a PlaywrightPulseReport
2045
+ historicalRuns.push(runJsonData);
2046
+ } catch (fileReadError) {
2047
+ console.warn(
2048
+ chalk.yellow(
2049
+ `Could not read/parse history file ${fileMeta.name}: ${fileReadError.message}`
2050
+ )
2051
+ );
2204
2052
  }
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
2053
  }
2054
+ // Reverse to have oldest first for chart data series (if charts expect chronological)
2055
+ historicalRuns.reverse();
2056
+ console.log(
2057
+ chalk.green(
2058
+ `Loaded ${historicalRuns.length} historical run(s) for trend analysis.`
2059
+ )
2060
+ );
2220
2061
  } catch (error) {
2221
2062
  if (error.code === "ENOENT") {
2222
2063
  console.warn(
2223
2064
  chalk.yellow(
2224
- `Warning: Trend data file not found at ${trendDataPath}. Report will be generated without historical trends.`
2065
+ `History directory '${historyDir}' not found. No historical trends will be displayed.`
2225
2066
  )
2226
2067
  );
2227
2068
  } else {
2228
2069
  console.warn(
2229
2070
  chalk.yellow(
2230
- `Warning: Could not read or process trend data from ${trendDataPath}. Report will be generated without historical trends. Error: ${error.message}`
2071
+ `Error loading historical data from '${historyDir}': ${error.message}`
2231
2072
  )
2232
2073
  );
2233
2074
  }
2234
2075
  }
2235
2076
 
2077
+ // Step 4: Prepare trendData object in the format expected by chart functions
2078
+ const trendData = {
2079
+ overall: [], // For overall run summaries (passed, failed, skipped, duration over time)
2080
+ testRuns: {}, // For individual test history (key: "test run <run_timestamp_ms>", value: array of test result summaries)
2081
+ };
2082
+
2083
+ if (historicalRuns.length > 0) {
2084
+ historicalRuns.forEach((histRunReport) => {
2085
+ // histRunReport is a full PlaywrightPulseReport object from a past run
2086
+ if (histRunReport.run) {
2087
+ // Ensure timestamp is a Date object for correct sorting/comparison later if needed by charts
2088
+ const runTimestamp = new Date(histRunReport.run.timestamp);
2089
+ trendData.overall.push({
2090
+ runId: runTimestamp.getTime(), // Use timestamp as a unique ID for this context
2091
+ timestamp: runTimestamp,
2092
+ duration: histRunReport.run.duration,
2093
+ totalTests: histRunReport.run.totalTests,
2094
+ passed: histRunReport.run.passed,
2095
+ failed: histRunReport.run.failed,
2096
+ skipped: histRunReport.run.skipped || 0,
2097
+ });
2098
+
2099
+ // For generateTestHistoryContent
2100
+ if (histRunReport.results && Array.isArray(histRunReport.results)) {
2101
+ const runKeyForTestHistory = `test run ${runTimestamp.getTime()}`; // Use timestamp to key test runs
2102
+ trendData.testRuns[runKeyForTestHistory] = histRunReport.results.map(
2103
+ (test) => ({
2104
+ testName: test.name, // Full test name path
2105
+ duration: test.duration,
2106
+ status: test.status,
2107
+ timestamp: new Date(test.startTime), // Assuming test.startTime exists and is what you need
2108
+ })
2109
+ );
2110
+ }
2111
+ }
2112
+ });
2113
+ // Ensure trendData.overall is sorted by timestamp if not already
2114
+ trendData.overall.sort(
2115
+ (a, b) => a.timestamp.getTime() - b.timestamp.getTime()
2116
+ );
2117
+ }
2118
+
2119
+ // Step 5: Generate and write HTML
2236
2120
  try {
2237
- const htmlContent = generateHTML(reportData, trendData);
2121
+ // currentRunReportData is for the main content (test list, summary cards of *this* run)
2122
+ // trendData is for the historical charts and test history section
2123
+ const htmlContent = generateHTML(currentRunReportData, trendData);
2238
2124
  await fs.writeFile(reportHtmlPath, htmlContent, "utf-8");
2239
2125
  console.log(
2240
2126
  chalk.green.bold(
2241
- `🎉 Enhanced report generated successfully at: ${reportHtmlPath}`
2127
+ `🎉 Pulse report generated successfully at: ${reportHtmlPath}`
2242
2128
  )
2243
2129
  );
2244
- console.log(chalk.gray(` (You can open this file in your browser)`));
2130
+ console.log(chalk.gray(`(You can open this file in your browser)`));
2245
2131
  } catch (error) {
2246
2132
  console.error(chalk.red(`Error generating HTML report: ${error.message}`));
2247
- console.error(chalk.red(error.stack));
2133
+ console.error(chalk.red(error.stack)); // Log full stack for HTML generation errors
2248
2134
  process.exit(1);
2249
2135
  }
2250
2136
  }
2251
2137
 
2138
+ // Make sure main() is called at the end of your script
2252
2139
  main().catch((err) => {
2253
2140
  console.error(
2254
2141
  chalk.red.bold(`Unhandled error during script execution: ${err.message}`)
2255
2142
  );
2256
2143
  console.error(err.stack);
2257
2144
  process.exit(1);
2258
- });
2145
+ });