@arghajit/playwright-pulse-report 0.2.1 → 0.2.2

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