@arghajit/playwright-pulse-report 0.2.1 → 0.2.3

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