@arghajit/dummy 0.1.0-beta-13 → 0.1.0-beta-15

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