@arghajit/dummy 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as fs from "fs/promises";
4
- import { readFileSync, existsSync as fsExistsSync } from "fs"; // ADD THIS LINE
4
+ import { readFileSync, existsSync as fsExistsSync } from "fs";
5
5
  import path from "path";
6
- import { fork } from "child_process"; // Add this
7
- import { fileURLToPath } from "url"; // Add this for resolving path in ESM
8
-
9
- // Use dynamic import for chalk as it's ESM only
6
+ import { fork } from "child_process";
7
+ import { fileURLToPath } from "url";
8
+
9
+ /**
10
+ * Dynamically imports the 'chalk' library for terminal string styling.
11
+ * This is necessary because chalk is an ESM-only module.
12
+ * If the import fails, a fallback object with plain console log functions is used.
13
+ */
10
14
  let chalk;
11
15
  try {
12
16
  chalk = (await import("chalk")).default;
@@ -22,135 +26,278 @@ try {
22
26
  };
23
27
  }
24
28
 
25
- // Default configuration
29
+ /**
30
+ * @constant {string} DEFAULT_OUTPUT_DIR
31
+ * The default directory where the report will be generated.
32
+ */
26
33
  const DEFAULT_OUTPUT_DIR = "pulse-report";
34
+
35
+ /**
36
+ * @constant {string} DEFAULT_JSON_FILE
37
+ * The default name for the JSON file containing the test data.
38
+ */
27
39
  const DEFAULT_JSON_FILE = "playwright-pulse-report.json";
40
+
41
+ /**
42
+ * @constant {string} DEFAULT_HTML_FILE
43
+ * The default name for the generated HTML report file.
44
+ */
28
45
  const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
29
46
 
30
47
  // Helper functions
48
+ /**
49
+ * Converts a string with ANSI escape codes to an HTML string with inline styles.
50
+ * @param {string} text The text with ANSI codes.
51
+ * @returns {string} The converted HTML string.
52
+ */
53
+ export function ansiToHtml(text) {
54
+ if (!text) {
55
+ return "";
56
+ }
57
+ const codes = {
58
+ 0: "color:inherit;font-weight:normal;font-style:normal;text-decoration:none;opacity:1;background-color:inherit;",
59
+ 1: "font-weight:bold",
60
+ 2: "opacity:0.6",
61
+ 3: "font-style:italic",
62
+ 4: "text-decoration:underline",
63
+ 30: "color:#000", // black
64
+ 31: "color:#d00", // red
65
+ 32: "color:#0a0", // green
66
+ 33: "color:#aa0", // yellow
67
+ 34: "color:#00d", // blue
68
+ 35: "color:#a0a", // magenta
69
+ 36: "color:#0aa", // cyan
70
+ 37: "color:#aaa", // light grey
71
+ 39: "color:inherit", // default foreground color
72
+ 40: "background-color:#000", // black background
73
+ 41: "background-color:#d00", // red background
74
+ 42: "background-color:#0a0", // green background
75
+ 43: "background-color:#aa0", // yellow background
76
+ 44: "background-color:#00d", // blue background
77
+ 45: "background-color:#a0a", // magenta background
78
+ 46: "background-color:#0aa", // cyan background
79
+ 47: "background-color:#aaa", // light grey background
80
+ 49: "background-color:inherit", // default background color
81
+ 90: "color:#555", // dark grey
82
+ 91: "color:#f55", // light red
83
+ 92: "color:#5f5", // light green
84
+ 93: "color:#ff5", // light yellow
85
+ 94: "color:#55f", // light blue
86
+ 95: "color:#f5f", // light magenta
87
+ 96: "color:#5ff", // light cyan
88
+ 97: "color:#fff", // white
89
+ };
90
+
91
+ let currentStylesArray = [];
92
+ let html = "";
93
+ let openSpan = false;
94
+
95
+ const applyStyles = () => {
96
+ if (openSpan) {
97
+ html += "</span>";
98
+ openSpan = false;
99
+ }
100
+ if (currentStylesArray.length > 0) {
101
+ const styleString = currentStylesArray.filter((s) => s).join(";");
102
+ if (styleString) {
103
+ html += `<span style="${styleString}">`;
104
+ openSpan = true;
105
+ }
106
+ }
107
+ };
108
+ const resetAndApplyNewCodes = (newCodesStr) => {
109
+ const newCodes = newCodesStr.split(";");
110
+ if (newCodes.includes("0")) {
111
+ currentStylesArray = [];
112
+ if (codes["0"]) currentStylesArray.push(codes["0"]);
113
+ }
114
+ for (const code of newCodes) {
115
+ if (code === "0") continue;
116
+ if (codes[code]) {
117
+ if (code === "39") {
118
+ currentStylesArray = currentStylesArray.filter(
119
+ (s) => !s.startsWith("color:")
120
+ );
121
+ currentStylesArray.push("color:inherit");
122
+ } else if (code === "49") {
123
+ currentStylesArray = currentStylesArray.filter(
124
+ (s) => !s.startsWith("background-color:")
125
+ );
126
+ currentStylesArray.push("background-color:inherit");
127
+ } else {
128
+ currentStylesArray.push(codes[code]);
129
+ }
130
+ } else if (code.startsWith("38;2;") || code.startsWith("48;2;")) {
131
+ const parts = code.split(";");
132
+ const type = parts[0] === "38" ? "color" : "background-color";
133
+ if (parts.length === 5) {
134
+ currentStylesArray = currentStylesArray.filter(
135
+ (s) => !s.startsWith(type + ":")
136
+ );
137
+ currentStylesArray.push(
138
+ `${type}:rgb(${parts[2]},${parts[3]},${parts[4]})`
139
+ );
140
+ }
141
+ }
142
+ }
143
+ applyStyles();
144
+ };
145
+ const segments = text.split(/(\x1b\[[0-9;]*m)/g);
146
+ for (const segment of segments) {
147
+ if (!segment) continue;
148
+ if (segment.startsWith("\x1b[") && segment.endsWith("m")) {
149
+ const command = segment.slice(2, -1);
150
+ resetAndApplyNewCodes(command);
151
+ } else {
152
+ const escapedContent = segment
153
+ .replace(/&/g, "&amp;")
154
+ .replace(/</g, "&lt;")
155
+ .replace(/>/g, "&gt;")
156
+ .replace(/"/g, "&quot;")
157
+ .replace(/'/g, "&#039;");
158
+ html += escapedContent;
159
+ }
160
+ }
161
+ if (openSpan) {
162
+ html += "</span>";
163
+ }
164
+ return html;
165
+ }
166
+ /**
167
+ * Sanitizes an HTML string by replacing special characters with their corresponding HTML entities.
168
+ * @param {string} str The HTML string to sanitize.
169
+ * @returns {string} The sanitized HTML string.
170
+ */
31
171
  function sanitizeHTML(str) {
32
172
  if (str === null || str === undefined) return "";
33
- return String(str).replace(/[&<>"']/g, (match) => {
34
- const replacements = {
35
- "&": "&",
36
- "<": "<",
37
- ">": ">",
38
- '"': '"',
39
- "'": "'", // or '
40
- };
41
- return replacements[match] || match;
42
- });
173
+ return String(str).replace(
174
+ /[&<>"']/g,
175
+ (match) =>
176
+ ({ "&": "&", "<": "<", ">": ">", '"': '"', "'": "'" }[match] || match)
177
+ );
43
178
  }
179
+ /**
180
+ * Capitalizes the first letter of a string and converts the rest to lowercase.
181
+ * @param {string} str The string to capitalize.
182
+ * @returns {string} The capitalized string.
183
+ */
44
184
  function capitalize(str) {
45
- if (!str) return ""; // Handle empty string
185
+ if (!str) return "";
46
186
  return str[0].toUpperCase() + str.slice(1).toLowerCase();
47
187
  }
48
-
188
+ /**
189
+ * Formats a Playwright error object or message into an HTML string.
190
+ * @param {Error|string} error The error object or message string.
191
+ * @returns {string} The formatted HTML error string.
192
+ */
49
193
  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
62
- );
63
-
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
- };
194
+ const commandOutput = ansiToHtml(error || error.message);
195
+ return convertPlaywrightErrorToHTML(commandOutput);
196
+ }
197
+ /**
198
+ * Converts a string containing Playwright-style error formatting to HTML.
199
+ * @param {string} str The error string.
200
+ * @returns {string} The HTML-formatted error string.
201
+ */
202
+ function convertPlaywrightErrorToHTML(str) {
203
+ if (!str) return "";
204
+ return str
205
+ .replace(/^(\s+)/gm, (match) =>
206
+ match.replace(/ /g, " ").replace(/\t/g, " ")
207
+ )
208
+ .replace(/<red>/g, '<span style="color: red;">')
209
+ .replace(/<green>/g, '<span style="color: green;">')
210
+ .replace(/<dim>/g, '<span style="opacity: 0.6;">')
211
+ .replace(/<intensity>/g, '<span style="font-weight: bold;">')
212
+ .replace(/<\/color>/g, "</span>")
213
+ .replace(/<\/intensity>/g, "</span>")
214
+ .replace(/\n/g, "<br>");
215
+ }
216
+ /**
217
+ * Formats a duration in milliseconds into a human-readable string (e.g., '1h 2m 3s', '4.5s').
218
+ * @param {number} ms The duration in milliseconds.
219
+ * @param {object} [options={}] Formatting options.
220
+ * @param {number} [options.precision=1] The number of decimal places for seconds.
221
+ * @param {string} [options.invalidInputReturn="N/A"] The string to return for invalid input.
222
+ * @param {string|null} [options.defaultForNullUndefinedNegative=null] The value for null, undefined, or negative inputs.
223
+ * @returns {string} The formatted duration string.
224
+ */
225
+ function formatDuration(ms, options = {}) {
226
+ const {
227
+ precision = 1,
228
+ invalidInputReturn = "N/A",
229
+ defaultForNullUndefinedNegative = null,
230
+ } = options;
231
+
232
+ const validPrecision = Math.max(0, Math.floor(precision));
233
+ const zeroWithPrecision = (0).toFixed(validPrecision) + "s";
234
+ const resolvedNullUndefNegReturn =
235
+ defaultForNullUndefinedNegative === null
236
+ ? zeroWithPrecision
237
+ : defaultForNullUndefinedNegative;
238
+
239
+ if (ms === undefined || ms === null) {
240
+ return resolvedNullUndefNegReturn;
241
+ }
79
242
 
80
- // Build HTML output
81
- let html = `<div class="playwright-error">
82
- <div class="error-header">Test Error</div>`;
243
+ const numMs = Number(ms);
83
244
 
84
- if (timeoutMatch) {
85
- html += `<div class="error-timeout">⏱ Timeout: ${escapeHtml(
86
- timeoutMatch[1]
87
- )}ms</div>`;
245
+ if (Number.isNaN(numMs) || !Number.isFinite(numMs)) {
246
+ return invalidInputReturn;
88
247
  }
89
248
 
90
- if (assertionMatch) {
91
- html += `<div class="error-assertion">🔍 Assertion: expect(${escapeHtml(
92
- assertionMatch[1]
93
- )}).${escapeHtml(assertionMatch[2])}()</div>`;
249
+ if (numMs < 0) {
250
+ return resolvedNullUndefNegReturn;
94
251
  }
95
252
 
96
- if (expectedMatch) {
97
- html += `<div class="error-expected">✅ Expected: ${escapeHtml(
98
- expectedMatch[1]
99
- )}</div>`;
253
+ if (numMs === 0) {
254
+ return zeroWithPrecision;
100
255
  }
101
256
 
102
- if (actualMatch) {
103
- html += `<div class="error-actual">❌ Actual: ${escapeHtml(
104
- actualMatch[1]
105
- )}</div>`;
106
- }
257
+ const MS_PER_SECOND = 1000;
258
+ const SECONDS_PER_MINUTE = 60;
259
+ const MINUTES_PER_HOUR = 60;
260
+ const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
107
261
 
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
- }
262
+ const totalRawSeconds = numMs / MS_PER_SECOND;
127
263
 
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
- }
264
+ if (
265
+ totalRawSeconds < SECONDS_PER_MINUTE &&
266
+ Math.ceil(totalRawSeconds) < SECONDS_PER_MINUTE
267
+ ) {
268
+ return `${totalRawSeconds.toFixed(validPrecision)}s`;
269
+ } else {
270
+ const totalMsRoundedUpToSecond =
271
+ Math.ceil(numMs / MS_PER_SECOND) * MS_PER_SECOND;
142
272
 
143
- html += `</div>`;
273
+ let remainingMs = totalMsRoundedUpToSecond;
144
274
 
145
- return html;
146
- }
275
+ const h = Math.floor(remainingMs / (MS_PER_SECOND * SECONDS_PER_HOUR));
276
+ remainingMs %= MS_PER_SECOND * SECONDS_PER_HOUR;
147
277
 
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
- }
278
+ const m = Math.floor(remainingMs / (MS_PER_SECOND * SECONDS_PER_MINUTE));
279
+ remainingMs %= MS_PER_SECOND * SECONDS_PER_MINUTE;
280
+
281
+ const s = Math.floor(remainingMs / MS_PER_SECOND);
153
282
 
283
+ const parts = [];
284
+ if (h > 0) {
285
+ parts.push(`${h}h`);
286
+ }
287
+ if (h > 0 || m > 0 || numMs >= MS_PER_SECOND * SECONDS_PER_MINUTE) {
288
+ parts.push(`${m}m`);
289
+ }
290
+ parts.push(`${s}s`);
291
+
292
+ return parts.join(" ");
293
+ }
294
+ }
295
+ /**
296
+ * Generates HTML and JavaScript for a Highcharts line chart to display test result trends over multiple runs.
297
+ * @param {object} trendData The trend data.
298
+ * @param {Array<object>} trendData.overall An array of run objects with test statistics.
299
+ * @returns {string} The HTML string for the test trends chart.
300
+ */
154
301
  function generateTestTrendsChart(trendData) {
155
302
  if (!trendData || !trendData.overall || trendData.overall.length === 0) {
156
303
  return '<div class="no-data">No overall trend data available for test counts.</div>';
@@ -159,107 +306,99 @@ function generateTestTrendsChart(trendData) {
159
306
  const chartId = `testTrendsChart-${Date.now()}-${Math.random()
160
307
  .toString(36)
161
308
  .substring(2, 7)}`;
309
+ const renderFunctionName = `renderTestTrendsChart_${chartId.replace(
310
+ /-/g,
311
+ "_"
312
+ )}`;
162
313
  const runs = trendData.overall;
163
314
 
164
315
  const series = [
165
316
  {
166
317
  name: "Total",
167
318
  data: runs.map((r) => r.totalTests),
168
- color: "var(--primary-color)", // Blue
319
+ color: "var(--primary-color)",
169
320
  marker: { symbol: "circle" },
170
321
  },
171
322
  {
172
323
  name: "Passed",
173
324
  data: runs.map((r) => r.passed),
174
- color: "var(--success-color)", // Green
325
+ color: "var(--success-color)",
175
326
  marker: { symbol: "circle" },
176
327
  },
177
328
  {
178
329
  name: "Failed",
179
330
  data: runs.map((r) => r.failed),
180
- color: "var(--danger-color)", // Red
331
+ color: "var(--danger-color)",
181
332
  marker: { symbol: "circle" },
182
333
  },
183
334
  {
184
335
  name: "Skipped",
185
336
  data: runs.map((r) => r.skipped || 0),
186
- color: "var(--warning-color)", // Yellow
337
+ color: "var(--warning-color)",
187
338
  marker: { symbol: "circle" },
188
339
  },
189
340
  ];
190
-
191
- // Data needed by the tooltip formatter, stringified to be embedded in the client-side script
192
341
  const runsForTooltip = runs.map((r) => ({
193
342
  runId: r.runId,
194
343
  timestamp: r.timestamp,
195
344
  duration: r.duration,
196
345
  }));
197
346
 
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
- `;
347
+ const categoriesString = JSON.stringify(runs.map((run, i) => `Run ${i + 1}`));
348
+ const seriesString = JSON.stringify(series);
349
+ const runsForTooltipString = JSON.stringify(runsForTooltip);
242
350
 
243
351
  return `
244
- <div id="${chartId}" class="trend-chart-container"></div>
352
+ <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}">
353
+ <div class="no-data">Loading Test Volume Trends...</div>
354
+ </div>
245
355
  <script>
246
- document.addEventListener('DOMContentLoaded', function() {
356
+ window.${renderFunctionName} = function() {
357
+ const chartContainer = document.getElementById('${chartId}');
358
+ if (!chartContainer) { console.error("Chart container ${chartId} not found for lazy loading."); return; }
247
359
  if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
248
360
  try {
249
- const chartOptions = ${optionsObjectString};
361
+ chartContainer.innerHTML = ''; // Clear placeholder
362
+ const chartOptions = {
363
+ chart: { type: "line", height: 350, backgroundColor: "transparent" },
364
+ title: { text: null },
365
+ xAxis: { categories: ${categoriesString}, crosshair: true, labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}},
366
+ yAxis: { title: { text: "Test Count", style: { color: 'var(--text-color)'} }, min: 0, labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}},
367
+ legend: { layout: "horizontal", align: "center", verticalAlign: "bottom", itemStyle: { fontSize: "12px", color: 'var(--text-color)' }},
368
+ plotOptions: { series: { marker: { radius: 4, states: { hover: { radius: 6 }}}, states: { hover: { halo: { size: 5, opacity: 0.1 }}}}, line: { lineWidth: 2.5 }},
369
+ tooltip: {
370
+ shared: true, useHTML: true, backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5' },
371
+ formatter: function () {
372
+ const runsData = ${runsForTooltipString};
373
+ const pointIndex = this.points[0].point.x;
374
+ const run = runsData[pointIndex];
375
+ let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' + 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br><br>';
376
+ this.points.forEach(point => { tooltip += '<span style="color:' + point.color + '">●</span> ' + point.series.name + ': <b>' + point.y + '</b><br>'; });
377
+ tooltip += '<br>Duration: ' + formatDuration(run.duration);
378
+ return tooltip;
379
+ }
380
+ },
381
+ series: ${seriesString},
382
+ credits: { enabled: false }
383
+ };
250
384
  Highcharts.chart('${chartId}', chartOptions);
251
385
  } 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>';
386
+ console.error("Error rendering chart ${chartId} (lazy):", e);
387
+ chartContainer.innerHTML = '<div class="no-data">Error rendering test trends chart.</div>';
254
388
  }
255
389
  } else {
256
- document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
390
+ chartContainer.innerHTML = '<div class="no-data">Charting library not available for test trends.</div>';
257
391
  }
258
- });
392
+ };
259
393
  </script>
260
394
  `;
261
395
  }
262
-
396
+ /**
397
+ * Generates HTML and JavaScript for a Highcharts area chart to display test duration trends.
398
+ * @param {object} trendData Data for duration trends.
399
+ * @param {Array<object>} trendData.overall Array of objects, each representing a test run with a duration.
400
+ * @returns {string} The HTML string for the duration trend chart.
401
+ */
263
402
  function generateDurationTrendChart(trendData) {
264
403
  if (!trendData || !trendData.overall || trendData.overall.length === 0) {
265
404
  return '<div class="no-data">No overall trend data available for durations.</div>';
@@ -267,109 +406,88 @@ function generateDurationTrendChart(trendData) {
267
406
  const chartId = `durationTrendChart-${Date.now()}-${Math.random()
268
407
  .toString(36)
269
408
  .substring(2, 7)}`;
409
+ const renderFunctionName = `renderDurationTrendChart_${chartId.replace(
410
+ /-/g,
411
+ "_"
412
+ )}`;
270
413
  const runs = trendData.overall;
271
414
 
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
- }]`;
415
+ const accentColorAltRGB = "255, 152, 0"; // Assuming var(--accent-color-alt) is Orange #FF9800
293
416
 
417
+ const chartDataString = JSON.stringify(runs.map((run) => run.duration));
418
+ const categoriesString = JSON.stringify(runs.map((run, i) => `Run ${i + 1}`));
294
419
  const runsForTooltip = runs.map((r) => ({
295
420
  runId: r.runId,
296
421
  timestamp: r.timestamp,
297
422
  duration: r.duration,
298
423
  totalTests: r.totalTests,
299
424
  }));
425
+ const runsForTooltipString = JSON.stringify(runsForTooltip);
300
426
 
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
- `;
427
+ const seriesStringForRender = `[{
428
+ name: 'Duration',
429
+ data: ${chartDataString},
430
+ color: 'var(--accent-color-alt)',
431
+ type: 'area',
432
+ marker: { symbol: 'circle', enabled: true, radius: 4, states: { hover: { radius: 6, lineWidthPlus: 0 } } },
433
+ fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, 'rgba(${accentColorAltRGB}, 0.4)'], [1, 'rgba(${accentColorAltRGB}, 0.05)']] },
434
+ lineWidth: 2.5
435
+ }]`;
352
436
 
353
437
  return `
354
- <div id="${chartId}" class="trend-chart-container"></div>
438
+ <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}">
439
+ <div class="no-data">Loading Duration Trends...</div>
440
+ </div>
355
441
  <script>
356
- document.addEventListener('DOMContentLoaded', function() {
442
+ window.${renderFunctionName} = function() {
443
+ const chartContainer = document.getElementById('${chartId}');
444
+ if (!chartContainer) { console.error("Chart container ${chartId} not found for lazy loading."); return; }
357
445
  if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
358
446
  try {
359
- const chartOptions = ${optionsObjectString};
447
+ chartContainer.innerHTML = ''; // Clear placeholder
448
+ const chartOptions = {
449
+ chart: { type: 'area', height: 350, backgroundColor: 'transparent' },
450
+ title: { text: null },
451
+ xAxis: { categories: ${categoriesString}, crosshair: true, labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}},
452
+ yAxis: {
453
+ title: { text: 'Duration', style: { color: 'var(--text-color)' } },
454
+ labels: { formatter: function() { return formatDuration(this.value); }, style: { color: 'var(--text-color-secondary)', fontSize: '12px' }},
455
+ min: 0
456
+ },
457
+ legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom', itemStyle: { fontSize: '12px', color: 'var(--text-color)' }},
458
+ plotOptions: { area: { lineWidth: 2.5, states: { hover: { lineWidthPlus: 0 } }, threshold: null }},
459
+ tooltip: {
460
+ shared: true, useHTML: true, backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5' },
461
+ formatter: function () {
462
+ const runsData = ${runsForTooltipString};
463
+ const pointIndex = this.points[0].point.x;
464
+ const run = runsData[pointIndex];
465
+ let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' + 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br>';
466
+ this.points.forEach(point => { tooltip += '<span style="color:' + point.series.color + '">●</span> ' + point.series.name + ': <b>' + formatDuration(point.y) + '</b><br>'; });
467
+ tooltip += '<br>Tests: ' + run.totalTests;
468
+ return tooltip;
469
+ }
470
+ },
471
+ series: ${seriesStringForRender}, // This is already a string representation of an array
472
+ credits: { enabled: false }
473
+ };
360
474
  Highcharts.chart('${chartId}', chartOptions);
361
475
  } 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>';
476
+ console.error("Error rendering chart ${chartId} (lazy):", e);
477
+ chartContainer.innerHTML = '<div class="no-data">Error rendering duration trend chart.</div>';
364
478
  }
365
479
  } else {
366
- document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>';
480
+ chartContainer.innerHTML = '<div class="no-data">Charting library not available for duration trends.</div>';
367
481
  }
368
- });
482
+ };
369
483
  </script>
370
484
  `;
371
485
  }
372
-
486
+ /**
487
+ * Formats a date string or Date object into a more readable format (e.g., "MM/DD/YY HH:MM").
488
+ * @param {string|Date} dateStrOrDate The date string or Date object to format.
489
+ * @returns {string} The formatted date string, or "N/A" for invalid dates.
490
+ */
373
491
  function formatDate(dateStrOrDate) {
374
492
  if (!dateStrOrDate) return "N/A";
375
493
  try {
@@ -388,11 +506,15 @@ function formatDate(dateStrOrDate) {
388
506
  return "Invalid Date Format";
389
507
  }
390
508
  }
391
-
509
+ /**
510
+ * Generates a small area chart showing the duration history of a single test across multiple runs.
511
+ * The status of each run is indicated by the color of the marker.
512
+ * @param {Array<object>} history An array of run objects, each with status and duration.
513
+ * @returns {string} The HTML string for the test history chart.
514
+ */
392
515
  function generateTestHistoryChart(history) {
393
516
  if (!history || history.length === 0)
394
517
  return '<div class="no-data-chart">No data for chart</div>';
395
-
396
518
  const validHistory = history.filter(
397
519
  (h) => h && typeof h.duration === "number" && h.duration >= 0
398
520
  );
@@ -402,6 +524,10 @@ function generateTestHistoryChart(history) {
402
524
  const chartId = `testHistoryChart-${Date.now()}-${Math.random()
403
525
  .toString(36)
404
526
  .substring(2, 7)}`;
527
+ const renderFunctionName = `renderTestHistoryChart_${chartId.replace(
528
+ /-/g,
529
+ "_"
530
+ )}`;
405
531
 
406
532
  const seriesDataPoints = validHistory.map((run) => {
407
533
  let color;
@@ -431,94 +557,78 @@ function generateTestHistoryChart(history) {
431
557
  };
432
558
  });
433
559
 
434
- // Assuming var(--accent-color) is Deep Purple #673ab7 -> RGB 103, 58, 183
435
- const accentColorRGB = "103, 58, 183";
560
+ const accentColorRGB = "103, 58, 183"; // Assuming var(--accent-color) is Deep Purple #673ab7
436
561
 
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>';
562
+ const categoriesString = JSON.stringify(
563
+ validHistory.map((_, i) => `R${i + 1}`)
564
+ );
565
+ const seriesDataPointsString = JSON.stringify(seriesDataPoints);
489
566
 
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
567
  return `
503
- <div id="${chartId}" style="width: 320px; height: 100px;"></div>
568
+ <div id="${chartId}" style="width: 320px; height: 100px;" class="lazy-load-chart" data-render-function-name="${renderFunctionName}">
569
+ <div class="no-data-chart">Loading History...</div>
570
+ </div>
504
571
  <script>
505
- document.addEventListener('DOMContentLoaded', function() {
572
+ window.${renderFunctionName} = function() {
573
+ const chartContainer = document.getElementById('${chartId}');
574
+ if (!chartContainer) { console.error("Chart container ${chartId} not found for lazy loading."); return; }
506
575
  if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
507
576
  try {
508
- const chartOptions = ${optionsObjectString};
577
+ chartContainer.innerHTML = ''; // Clear placeholder
578
+ const chartOptions = {
579
+ chart: { type: 'area', height: 100, width: 320, backgroundColor: 'transparent', spacing: [10,10,15,35] },
580
+ title: { text: null },
581
+ xAxis: { categories: ${categoriesString}, labels: { style: { fontSize: '10px', color: 'var(--text-color-secondary)' }}},
582
+ yAxis: {
583
+ title: { text: null },
584
+ labels: { formatter: function() { return formatDuration(this.value); }, style: { fontSize: '10px', color: 'var(--text-color-secondary)' }, align: 'left', x: -35, y: 3 },
585
+ min: 0, gridLineWidth: 0, tickAmount: 4
586
+ },
587
+ legend: { enabled: false },
588
+ plotOptions: {
589
+ area: {
590
+ lineWidth: 2, lineColor: 'var(--accent-color)',
591
+ fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, 'rgba(${accentColorRGB}, 0.4)'],[1, 'rgba(${accentColorRGB}, 0)']]},
592
+ marker: { enabled: true }, threshold: null
593
+ }
594
+ },
595
+ tooltip: {
596
+ useHTML: true, backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5', padding: '8px' },
597
+ formatter: function() {
598
+ const pointData = this.point;
599
+ let statusBadgeHtml = '<span style="padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; background-color: ';
600
+ switch(String(pointData.status).toLowerCase()) {
601
+ case 'passed': statusBadgeHtml += 'var(--success-color)'; break;
602
+ case 'failed': statusBadgeHtml += 'var(--danger-color)'; break;
603
+ case 'skipped': statusBadgeHtml += 'var(--warning-color)'; break;
604
+ default: statusBadgeHtml += 'var(--dark-gray-color)';
605
+ }
606
+ statusBadgeHtml += ';">' + String(pointData.status).toUpperCase() + '</span>';
607
+ return '<strong>Run ' + (pointData.runId || (this.point.index + 1)) + '</strong><br>' + 'Status: ' + statusBadgeHtml + '<br>' + 'Duration: ' + formatDuration(pointData.y);
608
+ }
609
+ },
610
+ series: [{ data: ${seriesDataPointsString}, showInLegend: false }],
611
+ credits: { enabled: false }
612
+ };
509
613
  Highcharts.chart('${chartId}', chartOptions);
510
614
  } 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>';
615
+ console.error("Error rendering chart ${chartId} (lazy):", e);
616
+ chartContainer.innerHTML = '<div class="no-data-chart">Error rendering history chart.</div>';
513
617
  }
514
618
  } else {
515
- document.getElementById('${chartId}').innerHTML = '<div class="no-data-chart">Charting library not available.</div>';
619
+ chartContainer.innerHTML = '<div class="no-data-chart">Charting library not available for history.</div>';
516
620
  }
517
- });
621
+ };
518
622
  </script>
519
623
  `;
520
624
  }
521
-
625
+ /**
626
+ * Generates a Highcharts pie chart to visualize the distribution of test statuses.
627
+ * @param {Array<object>} data The data for the pie chart, with each object having a 'label' and 'value'.
628
+ * @param {number} [chartWidth=300] The width of the chart.
629
+ * @param {number} [chartHeight=300] The height of the chart.
630
+ * @returns {string} The HTML string for the pie chart.
631
+ */
522
632
  function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
523
633
  const total = data.reduce((sum, d) => sum + d.value, 0);
524
634
  if (total === 0) {
@@ -625,7 +735,7 @@ function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
625
735
  `;
626
736
 
627
737
  return `
628
- <div class="pie-chart-wrapper" style="align-items: center">
738
+ <div class="pie-chart-wrapper" style="align-items: center; max-height: 450px">
629
739
  <div style="display: flex; align-items: start; width: 100%;"><h3>Test Distribution</h3></div>
630
740
  <div id="${chartId}" style="width: ${chartWidth}px; height: ${
631
741
  chartHeight - 40
@@ -648,7 +758,631 @@ function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
648
758
  </div>
649
759
  `;
650
760
  }
761
+ /**
762
+ * Generates an HTML dashboard to display environment details.
763
+ * @param {object} environment The environment information.
764
+ * @param {number} [dashboardHeight=600] The height of the dashboard.
765
+ * @returns {string} The HTML string for the environment dashboard.
766
+ */
767
+ function generateEnvironmentDashboard(environment, dashboardHeight = 600) {
768
+ // Format memory for display
769
+ const formattedMemory = environment.memory.replace(/(\d+\.\d{2})GB/, "$1 GB");
770
+
771
+ // Generate a unique ID for the dashboard
772
+ const dashboardId = `envDashboard-${Date.now()}-${Math.random()
773
+ .toString(36)
774
+ .substring(2, 7)}`;
775
+
776
+ const cardHeight = Math.floor(dashboardHeight * 0.44);
777
+ const cardContentPadding = 16; // px
778
+
779
+ return `
780
+ <div class="environment-dashboard-wrapper" id="${dashboardId}">
781
+ <style>
782
+ .environment-dashboard-wrapper *,
783
+ .environment-dashboard-wrapper *::before,
784
+ .environment-dashboard-wrapper *::after {
785
+ box-sizing: border-box;
786
+ }
787
+
788
+ .environment-dashboard-wrapper {
789
+ --primary-color: #4a9eff;
790
+ --primary-light-color: #1a2332;
791
+ --secondary-color: #9ca3af;
792
+ --success-color: #34d399;
793
+ --success-light-color: #1a2e23;
794
+ --warning-color: #fbbf24;
795
+ --warning-light-color: #2d2a1a;
796
+ --danger-color: #f87171;
797
+
798
+ --background-color: #1f2937;
799
+ --card-background-color: #374151;
800
+ --text-color: #f9fafb;
801
+ --text-color-secondary: #d1d5db;
802
+ --border-color: #4b5563;
803
+ --border-light-color: #374151;
804
+ --icon-color: #d1d5db;
805
+ --chip-background: #4b5563;
806
+ --chip-text: #f9fafb;
807
+ --shadow-color: rgba(0, 0, 0, 0.3);
808
+
809
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
810
+ background-color: var(--background-color);
811
+ border-radius: 12px;
812
+ box-shadow: 0 6px 12px var(--shadow-color);
813
+ padding: 24px;
814
+ color: var(--text-color);
815
+ display: grid;
816
+ grid-template-columns: 1fr 1fr;
817
+ grid-template-rows: auto 1fr;
818
+ gap: 20px;
819
+ font-size: 14px;
820
+ }
821
+
822
+ .env-dashboard-header {
823
+ grid-column: 1 / -1;
824
+ display: flex;
825
+ justify-content: space-between;
826
+ align-items: center;
827
+ border-bottom: 1px solid var(--border-color);
828
+ padding-bottom: 16px;
829
+ margin-bottom: 8px;
830
+ }
831
+
832
+ .env-dashboard-title {
833
+ font-size: 1.5rem;
834
+ font-weight: 600;
835
+ color: var(--text-color);
836
+ margin: 0;
837
+ }
838
+
839
+ .env-dashboard-subtitle {
840
+ font-size: 0.875rem;
841
+ color: var(--text-color-secondary);
842
+ margin-top: 4px;
843
+ }
844
+
845
+ .env-card {
846
+ background-color: var(--card-background-color);
847
+ border-radius: 8px;
848
+ padding: ${cardContentPadding}px;
849
+ box-shadow: 0 3px 6px var(--shadow-color);
850
+ height: ${cardHeight}px;
851
+ display: flex;
852
+ flex-direction: column;
853
+ overflow: hidden;
854
+ }
855
+
856
+ .env-card-header {
857
+ font-weight: 600;
858
+ font-size: 1rem;
859
+ margin-bottom: 12px;
860
+ color: var(--text-color);
861
+ display: flex;
862
+ align-items: center;
863
+ padding-bottom: 8px;
864
+ border-bottom: 1px solid var(--border-light-color);
865
+ }
866
+
867
+ .env-card-header svg {
868
+ margin-right: 10px;
869
+ width: 18px;
870
+ height: 18px;
871
+ fill: var(--icon-color);
872
+ }
873
+
874
+ .env-card-content {
875
+ flex-grow: 1;
876
+ overflow-y: auto;
877
+ padding-right: 5px;
878
+ }
879
+
880
+ .env-detail-row {
881
+ display: flex;
882
+ justify-content: space-between;
883
+ align-items: center;
884
+ padding: 10px 0;
885
+ border-bottom: 1px solid var(--border-light-color);
886
+ font-size: 0.875rem;
887
+ }
888
+
889
+ .env-detail-row:last-child {
890
+ border-bottom: none;
891
+ }
892
+
893
+ .env-detail-label {
894
+ color: var(--text-color-secondary);
895
+ font-weight: 500;
896
+ margin-right: 10px;
897
+ }
898
+
899
+ .env-detail-value {
900
+ color: var(--text-color);
901
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
902
+ text-align: right;
903
+ word-break: break-all;
904
+ }
651
905
 
906
+ .env-chip {
907
+ display: inline-block;
908
+ padding: 4px 10px;
909
+ border-radius: 16px;
910
+ font-size: 0.75rem;
911
+ font-weight: 500;
912
+ line-height: 1.2;
913
+ background-color: var(--chip-background);
914
+ color: var(--chip-text);
915
+ }
916
+
917
+ .env-chip-primary {
918
+ background-color: var(--primary-light-color);
919
+ color: var(--primary-color);
920
+ }
921
+
922
+ .env-chip-success {
923
+ background-color: var(--success-light-color);
924
+ color: var(--success-color);
925
+ }
926
+
927
+ .env-chip-warning {
928
+ background-color: var(--warning-light-color);
929
+ color: var(--warning-color);
930
+ }
931
+
932
+ .env-cpu-cores {
933
+ display: flex;
934
+ align-items: center;
935
+ gap: 6px;
936
+ }
937
+
938
+ .env-core-indicator {
939
+ width: 12px;
940
+ height: 12px;
941
+ border-radius: 50%;
942
+ background-color: var(--success-color);
943
+ border: 1px solid rgba(255,255,255,0.2);
944
+ }
945
+
946
+ .env-core-indicator.inactive {
947
+ background-color: var(--border-light-color);
948
+ opacity: 0.7;
949
+ border-color: var(--border-color);
950
+ }
951
+ </style>
952
+
953
+ <div class="env-dashboard-header">
954
+ <div>
955
+ <h3 class="env-dashboard-title">System Environment</h3>
956
+ <p class="env-dashboard-subtitle">Snapshot of the execution environment</p>
957
+ </div>
958
+ <span class="env-chip env-chip-primary">${environment.host}</span>
959
+ </div>
960
+
961
+ <div class="env-card">
962
+ <div class="env-card-header">
963
+ <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>
964
+ Hardware
965
+ </div>
966
+ <div class="env-card-content">
967
+ <div class="env-detail-row">
968
+ <span class="env-detail-label">CPU Model</span>
969
+ <span class="env-detail-value">${environment.cpu.model}</span>
970
+ </div>
971
+ <div class="env-detail-row">
972
+ <span class="env-detail-label">CPU Cores</span>
973
+ <span class="env-detail-value">
974
+ <div class="env-cpu-cores">
975
+ ${Array.from(
976
+ { length: Math.max(0, environment.cpu.cores || 0) },
977
+ (_, i) =>
978
+ `<div class="env-core-indicator ${
979
+ i >=
980
+ (environment.cpu.cores >= 8 ? 8 : environment.cpu.cores)
981
+ ? "inactive"
982
+ : ""
983
+ }" title="Core ${i + 1}"></div>`
984
+ ).join("")}
985
+ <span>${environment.cpu.cores || "N/A"} cores</span>
986
+ </div>
987
+ </span>
988
+ </div>
989
+ <div class="env-detail-row">
990
+ <span class="env-detail-label">Memory</span>
991
+ <span class="env-detail-value">${formattedMemory}</span>
992
+ </div>
993
+ </div>
994
+ </div>
995
+
996
+ <div class="env-card">
997
+ <div class="env-card-header">
998
+ <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>
999
+ Operating System
1000
+ </div>
1001
+ <div class="env-card-content">
1002
+ <div class="env-detail-row">
1003
+ <span class="env-detail-label">OS Type</span>
1004
+ <span class="env-detail-value">${
1005
+ environment.os.split(" ")[0] === "darwin"
1006
+ ? "darwin (macOS)"
1007
+ : environment.os.split(" ")[0] || "Unknown"
1008
+ }</span>
1009
+ </div>
1010
+ <div class="env-detail-row">
1011
+ <span class="env-detail-label">OS Version</span>
1012
+ <span class="env-detail-value">${
1013
+ environment.os.split(" ")[1] || "N/A"
1014
+ }</span>
1015
+ </div>
1016
+ <div class="env-detail-row">
1017
+ <span class="env-detail-label">Hostname</span>
1018
+ <span class="env-detail-value" title="${environment.host}">${
1019
+ environment.host
1020
+ }</span>
1021
+ </div>
1022
+ </div>
1023
+ </div>
1024
+
1025
+ <div class="env-card">
1026
+ <div class="env-card-header">
1027
+ <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>
1028
+ Node.js Runtime
1029
+ </div>
1030
+ <div class="env-card-content">
1031
+ <div class="env-detail-row">
1032
+ <span class="env-detail-label">Node Version</span>
1033
+ <span class="env-detail-value">${environment.node}</span>
1034
+ </div>
1035
+ <div class="env-detail-row">
1036
+ <span class="env-detail-label">V8 Engine</span>
1037
+ <span class="env-detail-value">${environment.v8}</span>
1038
+ </div>
1039
+ <div class="env-detail-row">
1040
+ <span class="env-detail-label">Working Dir</span>
1041
+ <span class="env-detail-value" title="${environment.cwd}">${
1042
+ environment.cwd.length > 25
1043
+ ? "..." + environment.cwd.slice(-22)
1044
+ : environment.cwd
1045
+ }</span>
1046
+ </div>
1047
+ </div>
1048
+ </div>
1049
+
1050
+ <div class="env-card">
1051
+ <div class="env-card-header">
1052
+ <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>
1053
+ System Summary
1054
+ </div>
1055
+ <div class="env-card-content">
1056
+ <div class="env-detail-row">
1057
+ <span class="env-detail-label">Platform Arch</span>
1058
+ <span class="env-detail-value">
1059
+ <span class="env-chip ${
1060
+ environment.os.includes("darwin") &&
1061
+ environment.cpu.model.toLowerCase().includes("apple")
1062
+ ? "env-chip-success"
1063
+ : "env-chip-warning"
1064
+ }">
1065
+ ${
1066
+ environment.os.includes("darwin") &&
1067
+ environment.cpu.model.toLowerCase().includes("apple")
1068
+ ? "Apple Silicon"
1069
+ : environment.cpu.model.toLowerCase().includes("arm") ||
1070
+ environment.cpu.model.toLowerCase().includes("aarch64")
1071
+ ? "ARM-based"
1072
+ : "x86/Other"
1073
+ }
1074
+ </span>
1075
+ </span>
1076
+ </div>
1077
+ <div class="env-detail-row">
1078
+ <span class="env-detail-label">Memory per Core</span>
1079
+ <span class="env-detail-value">${
1080
+ environment.cpu.cores > 0
1081
+ ? (
1082
+ parseFloat(environment.memory) / environment.cpu.cores
1083
+ ).toFixed(2) + " GB"
1084
+ : "N/A"
1085
+ }</span>
1086
+ </div>
1087
+ <div class="env-detail-row">
1088
+ <span class="env-detail-label">Run Context</span>
1089
+ <span class="env-detail-value">CI/Local Test</span>
1090
+ </div>
1091
+ </div>
1092
+ </div>
1093
+ </div>
1094
+ `;
1095
+ }
1096
+ /**
1097
+ * Generates a Highcharts bar chart to visualize the distribution of test results across different workers.
1098
+ * @param {Array<object>} results The test results data.
1099
+ * @returns {string} The HTML string for the worker distribution chart and its associated modal.
1100
+ */
1101
+ function generateWorkerDistributionChart(results) {
1102
+ if (!results || results.length === 0) {
1103
+ return '<div class="no-data">No test results data available to display worker distribution.</div>';
1104
+ }
1105
+
1106
+ // 1. Sort results by startTime to ensure chronological order
1107
+ const sortedResults = [...results].sort((a, b) => {
1108
+ const timeA = a.startTime ? new Date(a.startTime).getTime() : 0;
1109
+ const timeB = b.startTime ? new Date(b.startTime).getTime() : 0;
1110
+ return timeA - timeB;
1111
+ });
1112
+
1113
+ const workerData = sortedResults.reduce((acc, test) => {
1114
+ const workerId =
1115
+ typeof test.workerId !== "undefined" ? test.workerId : "N/A";
1116
+ if (!acc[workerId]) {
1117
+ acc[workerId] = { passed: 0, failed: 0, skipped: 0, tests: [] };
1118
+ }
1119
+
1120
+ const status = String(test.status).toLowerCase();
1121
+ if (status === "passed" || status === "failed" || status === "skipped") {
1122
+ acc[workerId][status]++;
1123
+ }
1124
+
1125
+ const testTitleParts = test.name.split(" > ");
1126
+ const testTitle =
1127
+ testTitleParts[testTitleParts.length - 1] || "Unnamed Test";
1128
+ // Store both name and status for each test
1129
+ acc[workerId].tests.push({ name: testTitle, status: status });
1130
+
1131
+ return acc;
1132
+ }, {});
1133
+
1134
+ const workerIds = Object.keys(workerData).sort((a, b) => {
1135
+ if (a === "N/A") return 1;
1136
+ if (b === "N/A") return -1;
1137
+ return parseInt(a, 10) - parseInt(b, 10);
1138
+ });
1139
+
1140
+ if (workerIds.length === 0) {
1141
+ return '<div class="no-data">Could not determine worker distribution from test data.</div>';
1142
+ }
1143
+
1144
+ const chartId = `workerDistChart-${Date.now()}-${Math.random()
1145
+ .toString(36)
1146
+ .substring(2, 7)}`;
1147
+ const renderFunctionName = `renderWorkerDistChart_${chartId.replace(
1148
+ /-/g,
1149
+ "_"
1150
+ )}`;
1151
+ const modalJsNamespace = `modal_funcs_${chartId.replace(/-/g, "_")}`;
1152
+
1153
+ // The categories now just need the name for the axis labels
1154
+ const categories = workerIds.map((id) => `Worker ${id}`);
1155
+
1156
+ // We pass the full data separately to the script
1157
+ const fullWorkerData = workerIds.map((id) => ({
1158
+ id: id,
1159
+ name: `Worker ${id}`,
1160
+ tests: workerData[id].tests,
1161
+ }));
1162
+
1163
+ const passedData = workerIds.map((id) => workerData[id].passed);
1164
+ const failedData = workerIds.map((id) => workerData[id].failed);
1165
+ const skippedData = workerIds.map((id) => workerData[id].skipped);
1166
+
1167
+ const categoriesString = JSON.stringify(categories);
1168
+ const fullDataString = JSON.stringify(fullWorkerData);
1169
+ const seriesString = JSON.stringify([
1170
+ { name: "Passed", data: passedData, color: "var(--success-color)" },
1171
+ { name: "Failed", data: failedData, color: "var(--danger-color)" },
1172
+ { name: "Skipped", data: skippedData, color: "var(--warning-color)" },
1173
+ ]);
1174
+
1175
+ // The HTML now includes the chart container, the modal, and styles for the modal
1176
+ return `
1177
+ <style>
1178
+ .worker-modal-overlay {
1179
+ position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%;
1180
+ overflow: auto; background-color: rgba(0,0,0,0.8);
1181
+ display: none; align-items: center; justify-content: center;
1182
+ }
1183
+ .worker-modal-content {
1184
+ background-color: #1f2937;
1185
+ color: #f9fafb;
1186
+ margin: auto; padding: 20px; border: 1px solid #4b5563;
1187
+ width: 80%; max-width: 700px; border-radius: 8px;
1188
+ position: relative; box-shadow: 0 5px 15px rgba(0,0,0,0.7);
1189
+ }
1190
+ .worker-modal-close {
1191
+ position: absolute; top: 10px; right: 20px;
1192
+ font-size: 28px; font-weight: bold; cursor: pointer;
1193
+ line-height: 1; color: #d1d5db;
1194
+ }
1195
+ .worker-modal-close:hover, .worker-modal-close:focus {
1196
+ color: #f9fafb;
1197
+ }
1198
+ #worker-modal-body-${chartId} ul {
1199
+ list-style-type: none; padding-left: 0; margin-top: 15px; max-height: 45vh; overflow-y: auto;
1200
+ }
1201
+ #worker-modal-body-${chartId} li {
1202
+ padding: 8px 5px; border-bottom: 1px solid #4b5563;
1203
+ font-size: 0.9em; color: #f9fafb;
1204
+ }
1205
+ #worker-modal-body-${chartId} li:last-child {
1206
+ border-bottom: none;
1207
+ }
1208
+ #worker-modal-body-${chartId} li > span {
1209
+ display: inline-block;
1210
+ width: 70px;
1211
+ font-weight: bold;
1212
+ text-align: right;
1213
+ margin-right: 10px;
1214
+ color: #d1d5db;
1215
+ }
1216
+ </style>
1217
+
1218
+ <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}" style="min-height: 350px;">
1219
+ <div class="no-data">Loading Worker Distribution Chart...</div>
1220
+ </div>
1221
+
1222
+ <div id="worker-modal-${chartId}" class="worker-modal-overlay">
1223
+ <div class="worker-modal-content">
1224
+ <span class="worker-modal-close">×</span>
1225
+ <h3 id="worker-modal-title-${chartId}" style="text-align: center; margin-top: 0; margin-bottom: 25px; font-size: 1.25em; font-weight: 600; color: #fff"></h3>
1226
+ <div id="worker-modal-body-${chartId}"></div>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <script>
1231
+ // Namespace for modal functions to avoid global scope pollution
1232
+ window.${modalJsNamespace} = {};
1233
+
1234
+ window.${renderFunctionName} = function() {
1235
+ const chartContainer = document.getElementById('${chartId}');
1236
+ if (!chartContainer) { console.error("Chart container ${chartId} not found."); return; }
1237
+
1238
+ // --- Modal Setup ---
1239
+ const modal = document.getElementById('worker-modal-${chartId}');
1240
+ const modalTitle = document.getElementById('worker-modal-title-${chartId}');
1241
+ const modalBody = document.getElementById('worker-modal-body-${chartId}');
1242
+ const closeModalBtn = modal.querySelector('.worker-modal-close');
1243
+ if (modal && modal.parentElement !== document.body) {
1244
+ document.body.appendChild(modal);
1245
+ }
1246
+
1247
+ // Lightweight HTML escaper for client-side use
1248
+ function __escHtml(s){return String(s==null?'':s).replace(/[&<>\"]/g,function(ch){return ch==='&'?'&amp;':ch==='<'?'&lt;':ch==='>'?'&gt;':'&quot;';});}
1249
+
1250
+ window.${modalJsNamespace}.open = function(worker) {
1251
+ if (!worker) return;
1252
+ try {
1253
+ modalTitle.textContent = 'Test Details for ' + worker.name;
1254
+
1255
+ let testListHtml = '<ul>';
1256
+ if (worker.tests && worker.tests.length > 0) {
1257
+ worker.tests.forEach(test => {
1258
+ let color = 'inherit';
1259
+ if (test.status === 'passed') color = 'var(--success-color)';
1260
+ else if (test.status === 'failed') color = 'var(--danger-color)';
1261
+ else if (test.status === 'skipped') color = 'var(--warning-color)';
1262
+
1263
+ const safeName = __escHtml(test.name);
1264
+ testListHtml += '<li style="color: ' + color + ';"><span style="color: ' + color + '">[' + String(test.status).toUpperCase() + ']</span> ' + safeName + '</li>';
1265
+ });
1266
+ } else {
1267
+ testListHtml += '<li>No detailed test data available for this worker.</li>';
1268
+ }
1269
+ testListHtml += '</ul>';
1270
+
1271
+ modalBody.innerHTML = testListHtml;
1272
+ if (typeof openModal === 'function') openModal(); else modal.style.display = 'flex';
1273
+ } catch (err) {
1274
+ console.error('Failed to open worker modal:', err);
1275
+ }
1276
+ };
1277
+
1278
+ const closeModal = function() {
1279
+ modal.style.display = 'none';
1280
+ try { document.body.style.overflow = ''; } catch (_) {}
1281
+ };
1282
+
1283
+ const openModal = function() {
1284
+ modal.style.display = 'flex';
1285
+ try { document.body.style.overflow = 'hidden'; } catch (_) {}
1286
+ };
1287
+
1288
+ if (closeModalBtn) closeModalBtn.onclick = closeModal;
1289
+ modal.addEventListener('click', function(event) {
1290
+ if (event.target === modal) {
1291
+ closeModal();
1292
+ }
1293
+ });
1294
+
1295
+ document.addEventListener('keydown', function escHandler(e) {
1296
+ if (modal.style.display === 'flex' && (e.key === 'Escape' || e.key === 'Esc')) {
1297
+ closeModal();
1298
+ }
1299
+ });
1300
+
1301
+
1302
+ // --- Highcharts Setup ---
1303
+ if (typeof Highcharts !== 'undefined') {
1304
+ try {
1305
+ chartContainer.innerHTML = '';
1306
+ const fullData = ${fullDataString};
1307
+
1308
+ const chartOptions = {
1309
+ chart: { type: 'bar', height: 350, backgroundColor: 'transparent' },
1310
+ title: { text: null },
1311
+ xAxis: {
1312
+ categories: ${categoriesString},
1313
+ title: { text: 'Worker ID' },
1314
+ labels: { style: { color: 'var(--text-color-secondary)' }}
1315
+ },
1316
+ yAxis: {
1317
+ min: 0,
1318
+ title: { text: 'Number of Tests' },
1319
+ labels: { style: { color: 'var(--text-color-secondary)' }},
1320
+ stackLabels: { enabled: true, style: { fontWeight: 'bold', color: 'var(--text-color)' } }
1321
+ },
1322
+ legend: { reversed: true, itemStyle: { fontSize: "12px", color: 'var(--text-color)' } },
1323
+ plotOptions: {
1324
+ series: {
1325
+ stacking: 'normal',
1326
+ cursor: 'pointer',
1327
+ point: {
1328
+ events: {
1329
+ click: function () {
1330
+ // 'this.x' is the index of the category
1331
+ const workerData = fullData[this.x];
1332
+ window.${modalJsNamespace}.open(workerData);
1333
+ }
1334
+ }
1335
+ }
1336
+ }
1337
+ },
1338
+ tooltip: {
1339
+ shared: true,
1340
+ headerFormat: '<b>{point.key}</b> (Click for details)<br/>',
1341
+ pointFormat: '<span style="color:{series.color}">●</span> {series.name}: <b>{point.y}</b><br/>',
1342
+ footerFormat: 'Total: <b>{point.total}</b>'
1343
+ },
1344
+ series: ${seriesString},
1345
+ credits: { enabled: false }
1346
+ };
1347
+ Highcharts.chart('${chartId}', chartOptions);
1348
+ } catch (e) {
1349
+ console.error("Error rendering chart ${chartId}:", e);
1350
+ chartContainer.innerHTML = '<div class="no-data">Error rendering worker distribution chart.</div>';
1351
+ }
1352
+ } else {
1353
+ chartContainer.innerHTML = '<div class="no-data">Charting library not available for worker distribution.</div>';
1354
+ }
1355
+ };
1356
+ </script>
1357
+ `;
1358
+ }
1359
+ /**
1360
+ * A tooltip providing information about why worker -1 is special in Playwright.
1361
+ * @type {string}
1362
+ */
1363
+ const infoTooltip = `
1364
+ <span class="info-tooltip" style="display: inline-block; margin-left: 8px;">
1365
+ <span class="info-icon"
1366
+ style="cursor: pointer; font-size: 1.25rem;"
1367
+ onclick="window.workerInfoPrompt()">ℹ️</span>
1368
+ </span>
1369
+ <script>
1370
+ window.workerInfoPrompt = function() {
1371
+ const message = 'Why is worker -1 special?\\n\\n' +
1372
+ 'Playwright assigns skipped tests to worker -1 because:\\n' +
1373
+ '1. They don\\'t require browser execution\\n' +
1374
+ '2. This keeps real workers focused on actual tests\\n' +
1375
+ '3. Maintains clean reporting\\n\\n' +
1376
+ 'This is an intentional optimization by Playwright.';
1377
+ alert(message);
1378
+ }
1379
+ </script>
1380
+ `;
1381
+ /**
1382
+ * Generates the HTML content for the test history section.
1383
+ * @param {object} trendData - The historical trend data.
1384
+ * @returns {string} The HTML string for the test history content.
1385
+ */
652
1386
  function generateTestHistoryContent(trendData) {
653
1387
  if (
654
1388
  !trendData ||
@@ -734,7 +1468,7 @@ function generateTestHistoryContent(trendData) {
734
1468
  </span>
735
1469
  </div>
736
1470
  <div class="test-history-trend">
737
- ${generateTestHistoryChart(test.history)}
1471
+ ${generateTestHistoryChart(test.history)}
738
1472
  </div>
739
1473
  <details class="test-history-details-collapsible">
740
1474
  <summary>Show Run Details (${test.history.length})</summary>
@@ -768,7 +1502,11 @@ function generateTestHistoryContent(trendData) {
768
1502
  </div>
769
1503
  `;
770
1504
  }
771
-
1505
+ /**
1506
+ * Gets the CSS class for a given test status.
1507
+ * @param {string} status - The test status.
1508
+ * @returns {string} The CSS class for the status.
1509
+ */
772
1510
  function getStatusClass(status) {
773
1511
  switch (String(status).toLowerCase()) {
774
1512
  case "passed":
@@ -781,7 +1519,11 @@ function getStatusClass(status) {
781
1519
  return "status-unknown";
782
1520
  }
783
1521
  }
784
-
1522
+ /**
1523
+ * Gets the icon for a given test status.
1524
+ * @param {string} status - The test status.
1525
+ * @returns {string} The icon for the status.
1526
+ */
785
1527
  function getStatusIcon(status) {
786
1528
  switch (String(status).toLowerCase()) {
787
1529
  case "passed":
@@ -794,7 +1536,11 @@ function getStatusIcon(status) {
794
1536
  return "❓";
795
1537
  }
796
1538
  }
797
-
1539
+ /**
1540
+ * Processes test results to extract suite data.
1541
+ * @param {Array<object>} results - The test results.
1542
+ * @returns {Array<object>} An array of suite data objects.
1543
+ */
798
1544
  function getSuitesData(results) {
799
1545
  const suitesMap = new Map();
800
1546
  if (!results || results.length === 0) return [];
@@ -837,19 +1583,45 @@ function getSuitesData(results) {
837
1583
  if (currentStatus && suite[currentStatus] !== undefined) {
838
1584
  suite[currentStatus]++;
839
1585
  }
840
-
841
- if (currentStatus === "failed") {
842
- suite.statusOverall = "failed";
843
- } else if (
844
- currentStatus === "skipped" &&
845
- suite.statusOverall !== "failed"
846
- ) {
1586
+ if (currentStatus === "failed") suite.statusOverall = "failed";
1587
+ else if (currentStatus === "skipped" && suite.statusOverall !== "failed")
847
1588
  suite.statusOverall = "skipped";
848
- }
849
1589
  });
850
1590
  return Array.from(suitesMap.values());
851
1591
  }
852
-
1592
+ /**
1593
+ * Returns an icon for a given content type.
1594
+ * @param {string} contentType - The content type of the file.
1595
+ * @returns {string} The icon for the content type.
1596
+ */
1597
+ /**
1598
+ * Returns an icon for a given content type.
1599
+ * @param {string} contentType - The content type of the file.
1600
+ * @returns {string} The icon for the content type.
1601
+ */
1602
+ function getAttachmentIcon(contentType) {
1603
+ if (!contentType) return "📎"; // Handle undefined/null
1604
+
1605
+ const normalizedType = contentType.toLowerCase();
1606
+
1607
+ if (normalizedType.includes("pdf")) return "📄";
1608
+ if (normalizedType.includes("json")) return "{ }";
1609
+ if (/html/.test(normalizedType)) return "🌐"; // Fixed: regex for any HTML type
1610
+ if (normalizedType.includes("xml")) return "<>";
1611
+ if (normalizedType.includes("csv")) return "📊";
1612
+ if (normalizedType.startsWith("text/")) return "📝";
1613
+ return "📎";
1614
+ }
1615
+ /**
1616
+ * Generates the HTML for the suites widget.
1617
+ * @param {Array} suitesData - The data for the suites.
1618
+ * @returns {string} The HTML for the suites widget.
1619
+ */
1620
+ /**
1621
+ * Generates the HTML for the suites widget.
1622
+ * @param {Array} suitesData - The data for the suites.
1623
+ * @returns {string} The HTML for the suites widget.
1624
+ */
853
1625
  function generateSuitesWidget(suitesData) {
854
1626
  if (!suitesData || suitesData.length === 0) {
855
1627
  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 +1630,12 @@ function generateSuitesWidget(suitesData) {
858
1630
  <div class="suites-widget">
859
1631
  <div class="suites-header">
860
1632
  <h2>Test Suites</h2>
861
- <span class="summary-badge">
862
- ${suitesData.length} suites • ${suitesData.reduce(
1633
+ <span class="summary-badge">${
1634
+ suitesData.length
1635
+ } suites • ${suitesData.reduce(
863
1636
  (sum, suite) => sum + suite.count,
864
1637
  0
865
- )} tests
866
- </span>
1638
+ )} tests</span>
867
1639
  </div>
868
1640
  <div class="suites-grid">
869
1641
  ${suitesData
@@ -874,8 +1646,10 @@ function generateSuitesWidget(suitesData) {
874
1646
  <h3 class="suite-name" title="${sanitizeHTML(
875
1647
  suite.name
876
1648
  )} (${sanitizeHTML(suite.browser)})">${sanitizeHTML(suite.name)}</h3>
877
- <span class="browser-tag">${sanitizeHTML(suite.browser)}</span>
878
1649
  </div>
1650
+ <div>🖥️ <span class="browser-tag">${sanitizeHTML(
1651
+ suite.browser
1652
+ )}</span></div>
879
1653
  <div class="suite-card-body">
880
1654
  <span class="test-count">${suite.count} test${
881
1655
  suite.count !== 1 ? "s" : ""
@@ -904,7 +1678,109 @@ function generateSuitesWidget(suitesData) {
904
1678
  </div>
905
1679
  </div>`;
906
1680
  }
1681
+ /**
1682
+ * Generates the HTML for the AI failure analyzer tab.
1683
+ * @param {Array} results - The results of the test run.
1684
+ * @returns {string} The HTML for the AI failure analyzer tab.
1685
+ */
1686
+ function generateAIFailureAnalyzerTab(results) {
1687
+ const failedTests = (results || []).filter(
1688
+ (test) => test.status === "failed"
1689
+ );
1690
+
1691
+ if (failedTests.length === 0) {
1692
+ return `
1693
+ <h2 class="tab-main-title">AI Failure Analysis</h2>
1694
+ <div class="no-data">Congratulations! No failed tests in this run.</div>
1695
+ `;
1696
+ }
907
1697
 
1698
+ // btoa is not available in Node.js environment, so we define a simple polyfill for it.
1699
+ const btoa = (str) => Buffer.from(str).toString("base64");
1700
+
1701
+ return `
1702
+ <h2 class="tab-main-title">AI Failure Analysis</h2>
1703
+ <div class="ai-analyzer-stats">
1704
+ <div class="stat-item">
1705
+ <span class="stat-number">${failedTests.length}</span>
1706
+ <span class="stat-label">Failed Tests</span>
1707
+ </div>
1708
+ <div class="stat-item">
1709
+ <span class="stat-number">${
1710
+ new Set(failedTests.map((t) => t.browser)).size
1711
+ }</span>
1712
+ <span class="stat-label">Browsers</span>
1713
+ </div>
1714
+ <div class="stat-item">
1715
+ <span class="stat-number">${Math.round(
1716
+ failedTests.reduce((sum, test) => sum + (test.duration || 0), 0) /
1717
+ 1000
1718
+ )}s</span>
1719
+ <span class="stat-label">Total Duration</span>
1720
+ </div>
1721
+ </div>
1722
+ <p class="ai-analyzer-description">
1723
+ Analyze failed tests using AI to get suggestions and potential fixes. Click the AI Fix button for specific failed test.
1724
+ </p>
1725
+
1726
+ <div class="compact-failure-list">
1727
+ ${failedTests
1728
+ .map((test) => {
1729
+ const testTitle = test.name.split(" > ").pop() || "Unnamed Test";
1730
+ const testJson = btoa(JSON.stringify(test)); // Base64 encode the test object
1731
+ const truncatedError =
1732
+ (test.errorMessage || "No error message").slice(0, 150) +
1733
+ (test.errorMessage && test.errorMessage.length > 150 ? "..." : "");
1734
+
1735
+ return `
1736
+ <div class="compact-failure-item">
1737
+ <div class="failure-header">
1738
+ <div class="failure-main-info">
1739
+ <h3 class="failure-title" title="${sanitizeHTML(
1740
+ test.name
1741
+ )}">${sanitizeHTML(testTitle)}</h3>
1742
+ <div class="failure-meta">
1743
+ <span class="browser-indicator">${sanitizeHTML(
1744
+ test.browser || "unknown"
1745
+ )}</span>
1746
+ <span class="duration-indicator">${formatDuration(
1747
+ test.duration
1748
+ )}</span>
1749
+ </div>
1750
+ </div>
1751
+ <button class="compact-ai-btn" onclick="getAIFix(this)" data-test-json="${testJson}">
1752
+ <span class="ai-text">AI Fix</span>
1753
+ </button>
1754
+ </div>
1755
+ <div class="failure-error-preview">
1756
+ <div class="error-snippet">${formatPlaywrightError(
1757
+ truncatedError
1758
+ )}</div>
1759
+ <button class="expand-error-btn" onclick="toggleErrorDetails(this)">
1760
+ <span class="expand-text">Show Full Error</span>
1761
+ <span class="expand-icon">▼</span>
1762
+ </button>
1763
+ </div>
1764
+ <div class="full-error-details" style="display: none;">
1765
+ <div class="full-error-content">
1766
+ ${formatPlaywrightError(
1767
+ test.errorMessage || "No detailed error message available"
1768
+ )}
1769
+ </div>
1770
+ </div>
1771
+ </div>
1772
+ `;
1773
+ })
1774
+ .join("")}
1775
+ </div>
1776
+ `;
1777
+ }
1778
+ /**
1779
+ * Generates the HTML report.
1780
+ * @param {object} reportData - The data for the report.
1781
+ * @param {object} trendData - The data for the trend chart.
1782
+ * @returns {string} The HTML report.
1783
+ */
908
1784
  function generateHTML(reportData, trendData = null) {
909
1785
  const { run, results } = reportData;
910
1786
  const suitesData = getSuitesData(reportData.results || []);
@@ -916,8 +1792,7 @@ function generateHTML(reportData, trendData = null) {
916
1792
  duration: 0,
917
1793
  timestamp: new Date().toISOString(),
918
1794
  };
919
-
920
- const totalTestsOr1 = runSummary.totalTests || 1; // Avoid division by zero
1795
+ const totalTestsOr1 = runSummary.totalTests || 1;
921
1796
  const passPercentage = Math.round((runSummary.passed / totalTestsOr1) * 100);
922
1797
  const failPercentage = Math.round((runSummary.failed / totalTestsOr1) * 100);
923
1798
  const skipPercentage = Math.round(
@@ -928,18 +1803,20 @@ function generateHTML(reportData, trendData = null) {
928
1803
  ? formatDuration(runSummary.duration / runSummary.totalTests)
929
1804
  : "0.0s";
930
1805
 
931
- function generateTestCasesHTML() {
932
- if (!results || results.length === 0) {
1806
+ /**
1807
+ * Generates the HTML for the test cases.
1808
+ * @returns {string} The HTML for the test cases.
1809
+ */
1810
+ function generateTestCasesHTML(subset = results, baseIndex = 0) {
1811
+ if (!results || results.length === 0)
933
1812
  return '<div class="no-tests">No test results found in this run.</div>';
934
- }
935
-
936
- return results
937
- .map((test, index) => {
1813
+ return subset
1814
+ .map((test, i) => {
1815
+ const testIndex = baseIndex + i;
938
1816
  const browser = test.browser || "unknown";
939
1817
  const testFileParts = test.name.split(" > ");
940
1818
  const testTitle =
941
1819
  testFileParts[testFileParts.length - 1] || "Unnamed Test";
942
-
943
1820
  const generateStepsHTML = (steps, depth = 0) => {
944
1821
  if (!steps || steps.length === 0)
945
1822
  return "<div class='no-steps'>No steps recorded for this test.</div>";
@@ -951,902 +1828,683 @@ function generateHTML(reportData, trendData = null) {
951
1828
  ? `step-hook step-hook-${step.hookType}`
952
1829
  : "";
953
1830
  const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
954
-
955
- return `
956
- <div class="step-item" style="--depth: ${depth};">
957
- <div class="step-header ${stepClass}" role="button" aria-expanded="false">
958
- <span class="step-icon">${getStatusIcon(step.status)}</span>
959
- <span class="step-title">${sanitizeHTML(
1831
+ return `<div class="step-item" style="--depth: ${depth};"><div class="step-header ${stepClass}" role="button" aria-expanded="false"><span class="step-icon">${getStatusIcon(
1832
+ step.status
1833
+ )}</span><span class="step-title">${sanitizeHTML(
960
1834
  step.title
961
- )}${hookIndicator}</span>
962
- <span class="step-duration">${formatDuration(
1835
+ )}${hookIndicator}</span><span class="step-duration">${formatDuration(
963
1836
  step.duration
964
- )}</span>
965
- </div>
966
- <div class="step-details" style="display: none;">
967
- ${
1837
+ )}</span></div><div class="step-details" style="display: none;">${
968
1838
  step.codeLocation
969
- ? `<div class="step-info"><strong>Location:</strong> ${sanitizeHTML(
1839
+ ? `<div class="step-info code-section"><strong>Location:</strong> ${sanitizeHTML(
970
1840
  step.codeLocation
971
1841
  )}</div>`
972
1842
  : ""
973
- }
974
- ${
1843
+ }${
975
1844
  step.errorMessage
976
- ? `
977
- <div class="step-error">
978
- ${
979
- step.stackTrace
980
- ? `<pre class="stack-trace">${sanitizeHTML(
981
- step.stackTrace
982
- )}</pre>`
983
- : ""
984
- }
985
- </div>`
1845
+ ? `<div class="test-error-summary">${
1846
+ step.stackTrace
1847
+ ? `<div class="stack-trace">${formatPlaywrightError(
1848
+ step.stackTrace
1849
+ )}</div>`
1850
+ : ""
1851
+ }<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
986
1852
  : ""
987
- }
988
- ${
1853
+ }${
1854
+ (() => {
1855
+ if (!step.attachments || step.attachments.length === 0) return "";
1856
+ return `<div class="attachments-section"><h4>Step Attachments</h4><div class="attachments-grid">${step.attachments
1857
+ .map((attachment) => {
1858
+ try {
1859
+ const attachmentPath = path.resolve(
1860
+ DEFAULT_OUTPUT_DIR,
1861
+ attachment.path
1862
+ );
1863
+ if (!fsExistsSync(attachmentPath)) {
1864
+ return `<div class="attachment-item error">Attachment not found: ${sanitizeHTML(
1865
+ attachment.name
1866
+ )}</div>`;
1867
+ }
1868
+ const attachmentBase64 = readFileSync(attachmentPath).toString("base64");
1869
+ const attachmentDataUri = `data:${attachment.contentType};base64,${attachmentBase64}`;
1870
+ return `<div class="attachment-item generic-attachment">
1871
+ <div class="attachment-icon">${getAttachmentIcon(attachment.contentType)}</div>
1872
+ <div class="attachment-caption">
1873
+ <span class="attachment-name" title="${sanitizeHTML(attachment.name)}">${sanitizeHTML(attachment.name)}</span>
1874
+ <span class="attachment-type">${sanitizeHTML(attachment.contentType)}</span>
1875
+ </div>
1876
+ <div class="attachment-info">
1877
+ <div class="trace-actions">
1878
+ <a href="#" data-href="${attachmentDataUri}" class="view-full lazy-load-attachment" target="_blank">View</a>
1879
+ <a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(attachment.name)}">Download</a>
1880
+ </div>
1881
+ </div>
1882
+ </div>`;
1883
+ } catch (e) {
1884
+ return `<div class="attachment-item error">Failed to load attachment: ${sanitizeHTML(attachment.name)}</div>`;
1885
+ }
1886
+ })
1887
+ .join("")}</div></div>`;
1888
+ })()
1889
+ }${
989
1890
  hasNestedSteps
990
1891
  ? `<div class="nested-steps">${generateStepsHTML(
991
1892
  step.steps,
992
1893
  depth + 1
993
1894
  )}</div>`
994
1895
  : ""
995
- }
996
- </div>
997
- </div>`;
1896
+ }</div></div>`;
998
1897
  })
999
1898
  .join("");
1000
1899
  };
1001
-
1002
- return `
1003
- <div class="test-case" data-status="${
1004
- test.status
1005
- }" data-browser="${sanitizeHTML(browser)}" data-tags="${(test.tags || [])
1900
+ return `<div class="test-case" data-status="${
1901
+ test.status
1902
+ }" data-browser="${sanitizeHTML(browser)}" data-tags="${(
1903
+ test.tags || []
1904
+ )
1006
1905
  .join(",")
1007
- .toLowerCase()}">
1008
- <div class="test-case-header" role="button" aria-expanded="false">
1009
- <div class="test-case-summary">
1010
- <span class="status-badge ${getStatusClass(test.status)}">${String(
1906
+ .toLowerCase()}" data-test-id="${sanitizeHTML(String(test.id || testIndex))}">
1907
+ <div class="test-case-header" role="button" aria-expanded="false"><div class="test-case-summary"><span class="status-badge ${getStatusClass(
1908
+ test.status
1909
+ )}">${String(
1011
1910
  test.status
1012
- ).toUpperCase()}</span>
1013
- <span class="test-case-title" title="${sanitizeHTML(
1014
- test.name
1015
- )}">${sanitizeHTML(testTitle)}</span>
1016
- <span class="test-case-browser">(${sanitizeHTML(browser)})</span>
1017
- </div>
1018
- <div class="test-case-meta">
1019
- ${
1020
- test.tags && test.tags.length > 0
1021
- ? test.tags
1022
- .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1023
- .join(" ")
1024
- : ""
1025
- }
1026
- <span class="test-duration">${formatDuration(test.duration)}</span>
1027
- </div>
1028
- </div>
1029
- <div class="test-case-content" style="display: none;">
1030
- <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
1031
- ${
1032
- test.error
1033
- ? `<div class="test-error-summary">
1034
- ${formatPlaywrightError(test.error)}
1035
- </div>`
1036
- : ""
1037
- }
1038
-
1039
- <h4>Steps</h4>
1040
- <div class="steps-list">${generateStepsHTML(test.steps)}</div>
1041
-
1042
- ${
1043
- test.stdout && test.stdout.length > 0
1044
- ? `
1045
- <div class="console-output-section">
1046
- <h4>Console Output (stdout)</h4>
1047
- <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
1048
- .map((line) => sanitizeHTML(line))
1049
- .join("\n")}</pre>
1050
- </div>`
1051
- : ""
1052
- }
1053
- ${
1054
- test.stderr && test.stderr.length > 0
1055
- ? `
1056
- <div class="console-output-section">
1057
- <h4>Console Output (stderr)</h4>
1058
- <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
1059
- .map((line) => sanitizeHTML(line))
1060
- .join("\n")}</pre>
1061
- </div>`
1062
- : ""
1063
- }
1064
-
1065
- ${(() => {
1066
- if (
1067
- !test.screenshots ||
1068
- test.screenshots.length === 0
1069
- )
1070
- return "";
1071
-
1072
- // Define base output directory to resolve relative screenshot paths
1073
- // This assumes screenshot paths in your JSON are relative to DEFAULT_OUTPUT_DIR
1074
- const baseOutputDir = path.resolve(
1075
- process.cwd(),
1076
- DEFAULT_OUTPUT_DIR
1077
- );
1078
-
1079
- // Helper to escape HTML special characters (safer than the global sanitizeHTML)
1080
- const escapeHTML = (str) => {
1081
- if (str === null || str === undefined) return "";
1082
- return String(str).replace(
1083
- /[&<>"']/g,
1084
- (match) => {
1085
- const replacements = {
1086
- "&": "&",
1087
- "<": "<",
1088
- ">": ">",
1089
- '"': '"',
1090
- "'": "'",
1091
- };
1092
- return replacements[match] || match;
1093
- }
1094
- );
1095
- };
1096
-
1097
- const renderScreenshot = (
1098
- screenshotPathOrData,
1099
- index
1100
- ) => {
1101
- let base64ImageData = "";
1102
- const uniqueSuffix = `${Date.now()}-${index}-${Math.random()
1103
- .toString(36)
1104
- .substring(2, 7)}`;
1105
-
1911
+ ).toUpperCase()}</span><span class="test-case-title" title="${sanitizeHTML(
1912
+ test.name
1913
+ )}">${sanitizeHTML(
1914
+ testTitle
1915
+ )}</span><span class="test-case-browser">(${sanitizeHTML(
1916
+ browser
1917
+ )})</span></div><div class="test-case-meta">${
1918
+ test.tags && test.tags.length > 0
1919
+ ? test.tags
1920
+ .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1921
+ .join(" ")
1922
+ : ""
1923
+ }<span class="test-duration">${formatDuration(
1924
+ test.duration
1925
+ )}</span></div></div>
1926
+ <div class="test-case-content" style="display: none;">
1927
+ <p><strong>Full Path:</strong> ${sanitizeHTML(
1928
+ test.name
1929
+ )}</p>
1930
+ <p><strong>Test run Worker ID:</strong> ${sanitizeHTML(
1931
+ test.workerId
1932
+ )} [<strong>Total No. of Workers:</strong> ${sanitizeHTML(
1933
+ test.totalWorkers
1934
+ )}]</p>
1935
+ ${
1936
+ test.errorMessage
1937
+ ? `<div class="test-error-summary">${formatPlaywrightError(
1938
+ test.errorMessage
1939
+ )}<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
1940
+ : ""
1941
+ }
1942
+ ${
1943
+ test.snippet
1944
+ ? `<div class="code-section"><h4>Error Snippet</h4><pre><code>${formatPlaywrightError(
1945
+ test.snippet
1946
+ )}</code></pre></div>`
1947
+ : ""
1948
+ }
1949
+ <h4>Steps</h4><div class="steps-list">${generateStepsHTML(
1950
+ test.steps
1951
+ )}</div>
1952
+ ${(() => {
1953
+ if (!test.stdout || test.stdout.length === 0)
1954
+ return "";
1955
+ // Create a unique ID for the <pre> element to target it for copying
1956
+ const logId = `stdout-log-${test.id || testIndex}`;
1957
+ return `<div class="console-output-section">
1958
+ <h4>Console Output (stdout)
1959
+ <button class="copy-btn" onclick="copyLogContent('${logId}', this)">Copy Console</button>
1960
+ </h4>
1961
+ <div class="log-wrapper">
1962
+ <pre id="${logId}" class="console-log stdout-log" style="background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2;">${formatPlaywrightError(
1963
+ test.stdout
1964
+ .map((line) => sanitizeHTML(line))
1965
+ .join("\n")
1966
+ )}</pre>
1967
+ </div>
1968
+ </div>`;
1969
+ })()}
1970
+ ${
1971
+ test.stderr && test.stderr.length > 0
1972
+ ? (() => {
1973
+ const logId = `stderr-log-${test.id || testIndex}`;
1974
+ return `<div class="console-output-section"><h4>Console Output (stderr)</h4><pre id="${logId}" class="console-log stderr-log">${test.stderr
1975
+ .map((line) => sanitizeHTML(line))
1976
+ .join("\\n")}</pre></div>`;
1977
+ })()
1978
+ : ""
1979
+ }
1980
+
1981
+ ${(() => {
1982
+ if (
1983
+ !test.screenshots ||
1984
+ test.screenshots.length === 0
1985
+ )
1986
+ return "";
1987
+ return `<div class="attachments-section"><h4>Screenshots</h4><div class="attachments-grid">${test.screenshots
1988
+ .map((screenshotPath, index) => {
1106
1989
  try {
1107
- if (
1108
- typeof screenshotPathOrData === "string" &&
1109
- !screenshotPathOrData.startsWith("data:image")
1110
- ) {
1111
- // It's likely a file path, try to read and convert
1112
- const imagePath = path.resolve(
1113
- baseOutputDir,
1114
- screenshotPathOrData
1115
- );
1116
-
1117
- if (fsExistsSync(imagePath)) {
1118
- // Use imported fsExistsSync
1119
- const imageBuffer = readFileSync(imagePath); // Use imported readFileSync
1120
- base64ImageData =
1121
- imageBuffer.toString("base64");
1122
- } else {
1123
- console.warn(
1124
- chalk.yellow(
1125
- `[Reporter] Screenshot file not found: ${imagePath}`
1126
- )
1127
- );
1128
- return `<div class="attachment-item error" style="padding:10px; color:red;">Screenshot not found: ${escapeHTML(
1129
- screenshotPathOrData
1130
- )}</div>`;
1131
- }
1132
- } else if (
1133
- typeof screenshotPathOrData === "string" &&
1134
- screenshotPathOrData.startsWith(
1135
- "data:image/png;base64,"
1136
- )
1137
- ) {
1138
- // It's already a data URI, extract base64 part
1139
- base64ImageData =
1140
- screenshotPathOrData.substring(
1141
- "data:image/png;base64,".length
1142
- );
1143
- } else if (
1144
- typeof screenshotPathOrData === "string"
1145
- ) {
1146
- // Assume it's raw Base64 data if it's a string but not a known path or full data URI
1147
- base64ImageData = screenshotPathOrData;
1148
- } else {
1149
- console.warn(
1150
- chalk.yellow(
1151
- `[Reporter] Invalid screenshot data type for item at index ${index}.`
1152
- )
1990
+ const imagePath = path.resolve(
1991
+ DEFAULT_OUTPUT_DIR,
1992
+ screenshotPath
1993
+ );
1994
+ if (!fsExistsSync(imagePath))
1995
+ return `<div class="attachment-item error">Screenshot not found: ${sanitizeHTML(
1996
+ screenshotPath
1997
+ )}</div>`;
1998
+ const base64ImageData =
1999
+ readFileSync(imagePath).toString("base64");
2000
+ return `<div class="attachment-item"><img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" data-src="data:image/png;base64,${base64ImageData}" alt="Screenshot ${
2001
+ index + 1
2002
+ }" class="lazy-load-image"><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="data:image/png;base64,${base64ImageData}" class="lazy-load-attachment" target="_blank" download="screenshot-${index}.png">Download</a></div></div></div>`;
2003
+ } catch (e) {
2004
+ return `<div class="attachment-item error">Failed to load screenshot: ${sanitizeHTML(
2005
+ screenshotPath
2006
+ )}</div>`;
2007
+ }
2008
+ })
2009
+ .join("")}</div></div>`;
2010
+ })()}
2011
+
2012
+ ${(() => {
2013
+ if (!test.videoPath || test.videoPath.length === 0)
2014
+ return "";
2015
+ return `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${test.videoPath
2016
+ .map((videoPath, index) => {
2017
+ try {
2018
+ const videoFilePath = path.resolve(
2019
+ DEFAULT_OUTPUT_DIR,
2020
+ videoPath
2021
+ );
2022
+ if (!fsExistsSync(videoFilePath))
2023
+ return `<div class="attachment-item error">Video not found: ${sanitizeHTML(
2024
+ videoPath
2025
+ )}</div>`;
2026
+ const videoBase64 =
2027
+ readFileSync(videoFilePath).toString(
2028
+ "base64"
1153
2029
  );
1154
- return `<div class="attachment-item error" style="padding:10px; color:red;">Invalid screenshot data</div>`;
1155
- }
2030
+ const fileExtension = path
2031
+ .extname(videoPath)
2032
+ .slice(1)
2033
+ .toLowerCase();
2034
+ const mimeType =
2035
+ {
2036
+ mp4: "video/mp4",
2037
+ webm: "video/webm",
2038
+ ogg: "video/ogg",
2039
+ mov: "video/quicktime",
2040
+ avi: "video/x-msvideo",
2041
+ }[fileExtension] || "video/mp4";
2042
+ const videoDataUri = `data:${mimeType};base64,${videoBase64}`;
2043
+ return `<div class="attachment-item video-item"><video controls preload="none" class="lazy-load-video"><source data-src="${videoDataUri}" type="${mimeType}"></video><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="${videoDataUri}" class="lazy-load-attachment" target="_blank" download="video-${index}.${fileExtension}">Download</a></div></div></div>`;
2044
+ } catch (e) {
2045
+ return `<div class="attachment-item error">Failed to load video: ${sanitizeHTML(
2046
+ videoPath
2047
+ )}</div>`;
2048
+ }
2049
+ })
2050
+ .join("")}</div></div>`;
2051
+ })()}
2052
+
2053
+ ${(() => {
2054
+ if (!test.tracePath) return "";
2055
+ try {
2056
+ const traceFilePath = path.resolve(
2057
+ DEFAULT_OUTPUT_DIR,
2058
+ test.tracePath
2059
+ );
2060
+ if (!fsExistsSync(traceFilePath))
2061
+ return `<div class="attachments-section"><h4>Trace File</h4><div class="attachment-item error">Trace file not found: ${sanitizeHTML(
2062
+ test.tracePath
2063
+ )}</div></div>`;
2064
+ const traceBase64 =
2065
+ readFileSync(traceFilePath).toString("base64");
2066
+ const traceDataUri = `data:application/zip;base64,${traceBase64}`;
2067
+ return `<div class="attachments-section"><h4>Trace File</h4><div class="attachments-grid"><div class="attachment-item generic-attachment"><div class="attachment-icon">📄</div><div class="attachment-caption"><span class="attachment-name">trace.zip</span></div><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="${traceDataUri}" class="lazy-load-attachment" download="trace.zip">Download Trace</a></div></div></div></div></div>`;
2068
+ } catch (e) {
2069
+ return `<div class="attachments-section"><h4>Trace File</h4><div class="attachment-item error">Failed to load trace file.</div></div>`;
2070
+ }
2071
+ })()}
2072
+
2073
+ ${(() => {
2074
+ if (
2075
+ !test.attachments ||
2076
+ test.attachments.length === 0
2077
+ )
2078
+ return "";
2079
+
2080
+ return `<div class="attachments-section"><h4>Other Attachments</h4><div class="attachments-grid">${test.attachments
2081
+ .map((attachment) => {
2082
+ try {
2083
+ const attachmentPath = path.resolve(
2084
+ DEFAULT_OUTPUT_DIR,
2085
+ attachment.path
2086
+ );
1156
2087
 
1157
- if (!base64ImageData) {
1158
- // This case should ideally be caught above, but as a fallback:
2088
+ if (!fsExistsSync(attachmentPath)) {
1159
2089
  console.warn(
1160
- chalk.yellow(
1161
- `[Reporter] Could not obtain base64 data for screenshot: ${escapeHTML(
1162
- String(screenshotPathOrData)
1163
- )}`
1164
- )
2090
+ `Attachment not found at: ${attachmentPath}`
1165
2091
  );
1166
- return `<div class="attachment-item error" style="padding:10px; color:red;">Error loading screenshot: ${escapeHTML(
1167
- String(screenshotPathOrData)
2092
+ return `<div class="attachment-item error">Attachment not found: ${sanitizeHTML(
2093
+ attachment.name
1168
2094
  )}</div>`;
1169
2095
  }
1170
2096
 
1171
- return `
1172
- <div class="attachment-item">
1173
- <img src="data:image/png;base64,${base64ImageData}"
1174
- alt="Screenshot ${index + 1}"
1175
- loading="lazy"
1176
- onerror="this.alt='Error displaying embedded image'; this.style.display='none'; this.parentElement.innerHTML='<p style=\\'color:red;padding:10px;\\'>Error displaying screenshot ${
1177
- index + 1
1178
- }.</p>';">
1179
- <div class="attachment-info">
1180
- <div class="trace-actions">
1181
- <a href="data:image/png;base64,${base64ImageData}"
1182
- target="_blank"
1183
- class="view-full">
1184
- View Full Image
1185
- </a>
1186
- <a href="data:image/png;base64,${base64ImageData}"
1187
- target="_blank"
1188
- download="screenshot-${uniqueSuffix}.png">
1189
- Download
1190
- </a>
1191
- </div>
1192
- </div>
1193
- </div>`;
2097
+ const attachmentBase64 =
2098
+ readFileSync(attachmentPath).toString(
2099
+ "base64"
2100
+ );
2101
+ const attachmentDataUri = `data:${attachment.contentType};base64,${attachmentBase64}`;
2102
+
2103
+ return `<div class="attachment-item generic-attachment">
2104
+ <div class="attachment-icon">${getAttachmentIcon(
2105
+ attachment.contentType
2106
+ )}</div>
2107
+ <div class="attachment-caption">
2108
+ <span class="attachment-name" title="${sanitizeHTML(
2109
+ attachment.name
2110
+ )}">${sanitizeHTML(
2111
+ attachment.name
2112
+ )}</span>
2113
+ <span class="attachment-type">${sanitizeHTML(
2114
+ attachment.contentType
2115
+ )}</span>
2116
+ </div>
2117
+ <div class="attachment-info">
2118
+ <div class="trace-actions">
2119
+ <a href="#" data-href="${attachmentDataUri}" class="view-full lazy-load-attachment" target="_blank">View</a>
2120
+ <a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(
2121
+ attachment.name
2122
+ )}">Download</a>
2123
+ </div>
2124
+ </div>
2125
+ </div>`;
1194
2126
  } catch (e) {
1195
2127
  console.error(
1196
- chalk.red(
1197
- `[Reporter] Error processing screenshot ${escapeHTML(
1198
- String(screenshotPathOrData)
1199
- )}: ${e.message}`
1200
- )
2128
+ `Failed to process attachment "${attachment.name}":`,
2129
+ e
1201
2130
  );
1202
- return `<div class="attachment-item error" style="padding:10px; color:red;">Failed to load screenshot: ${escapeHTML(
1203
- String(screenshotPathOrData)
2131
+ return `<div class="attachment-item error">Failed to load attachment: ${sanitizeHTML(
2132
+ attachment.name
1204
2133
  )}</div>`;
1205
2134
  }
1206
- }; // end of renderScreenshot
1207
-
1208
- return `
1209
- <div class="attachments-section">
1210
- <h4>Screenshots (${test.screenshots.length})</h4>
1211
- <div class="attachments-grid">
1212
- ${test.screenshots.map(renderScreenshot).join("")}
1213
- </div>
2135
+ })
2136
+ .join("")}</div></div>`;
2137
+ })()}
2138
+
2139
+ ${
2140
+ test.codeSnippet
2141
+ ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
2142
+ test.codeSnippet
2143
+ )}</code></pre></div>`
2144
+ : ""
2145
+ }
1214
2146
  </div>
1215
- `;
1216
- })()}
1217
-
1218
- ${
1219
- test.videoPath
1220
- ? `
1221
- <div class="attachments-section">
1222
- <h4>Videos</h4>
1223
- <div class="attachments-grid">
1224
- ${(() => {
1225
- // Handle both string and array cases
1226
- const videos = Array.isArray(test.videoPath)
1227
- ? test.videoPath
1228
- : [test.videoPath];
1229
- const mimeTypes = {
1230
- mp4: "video/mp4",
1231
- webm: "video/webm",
1232
- ogg: "video/ogg",
1233
- mov: "video/quicktime",
1234
- avi: "video/x-msvideo",
1235
- };
1236
-
1237
- return videos
1238
- .map((video, index) => {
1239
- const videoUrl =
1240
- typeof video === "object" ? video.url || "" : video;
1241
- const videoName =
1242
- typeof video === "object"
1243
- ? video.name || `Video ${index + 1}`
1244
- : `Video ${index + 1}`;
1245
- const fileExtension = videoUrl
1246
- .split(".")
1247
- .pop()
1248
- .toLowerCase();
1249
- const mimeType = mimeTypes[fileExtension] || "video/mp4";
1250
-
1251
- return `
1252
- <div class="attachment-item">
1253
- <video controls width="100%" height="auto" title="${videoName}">
1254
- <source src="${videoUrl}" type="${mimeType}">
1255
- Your browser does not support the video tag.
1256
- </video>
1257
- <div class="attachment-info">
1258
- <div class="trace-actions">
1259
- <a href="${videoUrl}" target="_blank" download="${videoName}.${fileExtension}">
1260
- Download
1261
- </a>
1262
- </div>
1263
- </div>
1264
- </div>
1265
- `;
1266
- })
1267
- .join("");
1268
- })()}
1269
- </div>
1270
- </div>
1271
- `
1272
- : ""
1273
- }
1274
-
1275
- ${
1276
- test.tracePath
1277
- ? `
1278
- <div class="attachments-section">
1279
- <h4>Trace Files</h4>
1280
- <div class="attachments-grid">
1281
- ${(() => {
1282
- // Handle both string and array cases
1283
- const traces = Array.isArray(test.tracePath)
1284
- ? test.tracePath
1285
- : [test.tracePath];
1286
-
1287
- return traces
1288
- .map((trace, index) => {
1289
- const traceUrl =
1290
- typeof trace === "object" ? trace.url || "" : trace;
1291
- const traceName =
1292
- typeof trace === "object"
1293
- ? trace.name || `Trace ${index + 1}`
1294
- : `Trace ${index + 1}`;
1295
- const traceFileName = traceUrl.split("/").pop();
1296
-
1297
- return `
1298
- <div class="attachment-item">
1299
- <div class="trace-preview">
1300
- <span class="trace-icon">📄</span>
1301
- <span class="trace-name">${traceName}</span>
1302
- </div>
1303
- <div class="attachment-info">
1304
- <div class="trace-actions">
1305
- <a href="${traceUrl}" target="_blank" download="${traceFileName}" class="download-trace">
1306
- Download
1307
- </a>
1308
- </div>
1309
- </div>
1310
- </div>
1311
- `;
1312
- })
1313
- .join("");
1314
- })()}
1315
- </div>
1316
- </div>
1317
- `
1318
- : ""
1319
- }
1320
-
1321
- ${
1322
- test.codeSnippet
1323
- ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
1324
- test.codeSnippet
1325
- )}</code></pre></div>`
1326
- : ""
1327
- }
1328
- </div>
1329
- </div>`;
2147
+ </div>`;
1330
2148
  })
1331
2149
  .join("");
1332
2150
  }
1333
-
1334
2151
  return `
1335
2152
  <!DOCTYPE html>
1336
2153
  <html lang="en">
1337
2154
  <head>
1338
2155
  <meta charset="UTF-8">
1339
2156
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1340
- <link rel="icon" type="image/png" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1341
- <link rel="apple-touch-icon" href="https://i.postimg.cc/XqVn1NhF/pulse.png">
1342
- <script src="https://code.highcharts.com/highcharts.js"></script>
1343
- <title>Playwright Pulse Report</title>
1344
- <style>
1345
- :root {
1346
- --primary-color: #3f51b5; /* Indigo */
1347
- --secondary-color: #ff4081; /* Pink */
1348
- --accent-color: #673ab7; /* Deep Purple */
1349
- --accent-color-alt: #FF9800; /* Orange for duration charts */
1350
- --success-color: #4CAF50; /* Green */
1351
- --danger-color: #F44336; /* Red */
1352
- --warning-color: #FFC107; /* Amber */
1353
- --info-color: #2196F3; /* Blue */
1354
- --light-gray-color: #f5f5f5;
1355
- --medium-gray-color: #e0e0e0;
1356
- --dark-gray-color: #757575;
1357
- --text-color: #333;
1358
- --text-color-secondary: #555;
1359
- --border-color: #ddd;
1360
- --background-color: #f8f9fa;
1361
- --card-background-color: #fff;
1362
- --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
1363
- --border-radius: 8px;
1364
- --box-shadow: 0 5px 15px rgba(0,0,0,0.08);
1365
- --box-shadow-light: 0 3px 8px rgba(0,0,0,0.05);
1366
- --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.07);
1367
- }
1368
-
1369
- /* General Highcharts styling */
1370
- .highcharts-background { fill: transparent; }
1371
- .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
1372
- .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
1373
- .highcharts-axis-title { fill: var(--text-color) !important; }
1374
- .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; }
1375
-
1376
- body {
1377
- font-family: var(--font-family);
1378
- margin: 0;
1379
- background-color: var(--background-color);
1380
- color: var(--text-color);
1381
- line-height: 1.65;
1382
- font-size: 16px;
1383
- }
1384
-
1385
- .container {
1386
- max-width: 1600px;
1387
- padding: 30px;
1388
- border-radius: var(--border-radius);
1389
- box-shadow: var(--box-shadow);
1390
- background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec);
1391
- }
1392
-
1393
- .header {
1394
- display: flex;
1395
- justify-content: space-between;
1396
- align-items: center;
1397
- flex-wrap: wrap;
1398
- padding-bottom: 25px;
1399
- border-bottom: 1px solid var(--border-color);
1400
- margin-bottom: 25px;
1401
- }
1402
- .header-title { display: flex; align-items: center; gap: 15px; }
1403
- .header h1 { margin: 0; font-size: 1.85em; font-weight: 600; color: var(--primary-color); }
1404
- #report-logo { height: 40px; width: 40px; border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);}
1405
- .run-info { font-size: 0.9em; text-align: right; color: var(--text-color-secondary); line-height:1.5;}
1406
- .run-info strong { color: var(--text-color); }
1407
-
1408
- .tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 30px; overflow-x: auto; }
1409
- .tab-button {
1410
- padding: 15px 25px; background: none; border: none; border-bottom: 3px solid transparent;
1411
- cursor: pointer; font-size: 1.1em; font-weight: 600; color: black;
1412
- transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap;
1413
- }
1414
- .tab-button:hover { color: var(--accent-color); }
1415
- .tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
1416
- .tab-content { display: none; animation: fadeIn 0.4s ease-out; }
1417
- .tab-content.active { display: block; }
1418
- @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
1419
-
1420
- .dashboard-grid {
1421
- display: grid;
1422
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
1423
- gap: 22px; margin-bottom: 35px;
1424
- }
1425
- .summary-card {
1426
- background-color: var(--card-background-color); border: 1px solid var(--border-color);
1427
- border-radius: var(--border-radius); padding: 22px; text-align: center;
1428
- box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease;
1429
- }
1430
- .summary-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow); }
1431
- .summary-card h3 { margin: 0 0 10px; font-size: 1.05em; font-weight: 500; color: var(--text-color-secondary); }
1432
- .summary-card .value { font-size: 2.4em; font-weight: 600; margin-bottom: 8px; }
1433
- .summary-card .trend-percentage { font-size: 1em; color: var(--dark-gray-color); }
1434
- .status-passed .value, .stat-passed svg { color: var(--success-color); }
1435
- .status-failed .value, .stat-failed svg { color: var(--danger-color); }
1436
- .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
1437
-
1438
- .dashboard-bottom-row {
1439
- display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
1440
- gap: 28px; align-items: stretch;
1441
- }
1442
- .pie-chart-wrapper, .suites-widget, .trend-chart {
1443
- background-color: var(--card-background-color); padding: 28px;
1444
- border-radius: var(--border-radius); box-shadow: var(--box-shadow-light);
1445
- display: flex; flex-direction: column;
1446
- }
1447
-
1448
- .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3 {
1449
- text-align: center; margin-top: 0; margin-bottom: 25px;
1450
- font-size: 1.25em; font-weight: 600; color: var(--text-color);
1451
- }
1452
- .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { /* For Highcharts containers */
1453
- flex-grow: 1;
1454
- min-height: 250px; /* Ensure charts have some min height */
1455
- }
1456
-
1457
- .chart-tooltip { /* This class was for D3, Highcharts has its own tooltip styling via JS/SVG */
1458
- /* Basic styling for Highcharts HTML tooltips can be done via .highcharts-tooltip span */
1459
- }
1460
- .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
1461
- .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
1462
- .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
1463
- .status-badge-small-tooltip.status-skipped { background-color: var(--warning-color); }
1464
- .status-badge-small-tooltip.status-unknown { background-color: var(--dark-gray-color); }
1465
-
1466
- .suites-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
1467
- .summary-badge { background-color: var(--light-gray-color); color: var(--text-color-secondary); padding: 7px 14px; border-radius: 16px; font-size: 0.9em; }
1468
- .suites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
1469
- .suite-card {
1470
- border: 1px solid var(--border-color); border-left-width: 5px;
1471
- border-radius: calc(var(--border-radius) / 1.5); padding: 20px;
1472
- background-color: var(--card-background-color); transition: box-shadow 0.2s ease, border-left-color 0.2s ease;
1473
- }
1474
- .suite-card:hover { box-shadow: var(--box-shadow); }
1475
- .suite-card.status-passed { border-left-color: var(--success-color); }
1476
- .suite-card.status-failed { border-left-color: var(--danger-color); }
1477
- .suite-card.status-skipped { border-left-color: var(--warning-color); }
1478
- .suite-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
1479
- .suite-name { font-weight: 600; font-size: 1.05em; color: var(--text-color); margin-right: 10px; word-break: break-word;}
1480
- .browser-tag { font-size: 0.8em; background-color: var(--medium-gray-color); color: var(--text-color-secondary); padding: 3px 8px; border-radius: 4px; white-space: nowrap;}
1481
- .suite-card-body .test-count { font-size: 0.95em; color: var(--text-color-secondary); display: block; margin-bottom: 10px; }
1482
- .suite-stats { display: flex; gap: 14px; font-size: 0.95em; align-items: center; }
1483
- .suite-stats span { display: flex; align-items: center; gap: 6px; }
1484
- .suite-stats svg { vertical-align: middle; font-size: 1.15em; }
1485
-
1486
- .filters {
1487
- display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 28px;
1488
- padding: 20px; background-color: var(--light-gray-color); border-radius: var(--border-radius);
1489
- box-shadow: var(--box-shadow-inset); border-color: black; border-style: groove;
1490
- }
1491
- .filters input, .filters select, .filters button {
1492
- padding: 11px 15px; border: 1px solid var(--border-color);
1493
- border-radius: 6px; font-size: 1em;
1494
- }
1495
- .filters input { flex-grow: 1; min-width: 240px;}
1496
- .filters select {min-width: 180px;}
1497
- .filters button { background-color: var(--primary-color); color: white; cursor: pointer; transition: background-color 0.2s ease, box-shadow 0.2s ease; border: none; }
1498
- .filters button:hover { background-color: var(--accent-color); box-shadow: 0 2px 5px rgba(0,0,0,0.15);}
1499
-
1500
- .test-case {
1501
- margin-bottom: 15px; border: 1px solid var(--border-color);
1502
- border-radius: var(--border-radius); background-color: var(--card-background-color);
1503
- box-shadow: var(--box-shadow-light); overflow: hidden;
1504
- }
1505
- .test-case-header {
1506
- padding: 10px 15px; background-color: #fff; cursor: pointer;
1507
- display: flex; justify-content: space-between; align-items: center;
1508
- border-bottom: 1px solid transparent;
1509
- transition: background-color 0.2s ease;
1510
- }
1511
- .test-case-header:hover { background-color: #f4f6f8; }
1512
- .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: #f9fafb; }
1513
-
1514
- .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
1515
- .test-case-title { font-weight: 600; color: var(--text-color); font-size: 1em; }
1516
- .test-case-browser { font-size: 0.9em; color: var(--text-color-secondary); }
1517
- .test-case-meta { display: flex; align-items: center; gap: 12px; font-size: 0.9em; color: var(--text-color-secondary); flex-shrink: 0; }
1518
- .test-duration { background-color: var(--light-gray-color); padding: 4px 10px; border-radius: 12px; font-size: 0.9em;}
1519
-
1520
- .status-badge {
1521
- padding: 5px; border-radius: 6px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase;
1522
- min-width: 70px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
1523
- }
1524
- .status-badge.status-passed { background-color: var(--success-color); }
1525
- .status-badge.status-failed { background-color: var(--danger-color); }
1526
- .status-badge.status-skipped { background-color: var(--warning-color); }
1527
- .status-badge.status-unknown { background-color: var(--dark-gray-color); }
1528
-
1529
- .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; }
1530
-
1531
- .test-case-content { display: none; padding: 20px; border-top: 1px solid var(--border-color); background-color: #fcfdff; }
1532
- .test-case-content h4 { margin-top: 22px; margin-bottom: 14px; font-size: 1.15em; color: var(--primary-color); }
1533
- .test-case-content p { margin-bottom: 10px; font-size: 1em; }
1534
- .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; }
1535
- .test-error-summary h4 { color: var(--danger-color); margin-top:0;}
1536
- .test-error-summary pre { white-space: pre-wrap; word-break: break-all; color: var(--danger-color); font-size: 0.95em;}
1537
-
1538
- .steps-list { margin: 18px 0; }
1539
- .step-item { margin-bottom: 8px; padding-left: calc(var(--depth, 0) * 28px); }
1540
- .step-header {
1541
- display: flex; align-items: center; cursor: pointer;
1542
- padding: 10px 14px; border-radius: 6px; background-color: #fff;
1543
- border: 1px solid var(--light-gray-color);
1544
- transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
1545
- }
1546
- .step-header:hover { background-color: #f0f2f5; border-color: var(--medium-gray-color); box-shadow: var(--box-shadow-inset); }
1547
- .step-icon { margin-right: 12px; width: 20px; text-align: center; font-size: 1.1em; }
1548
- .step-title { flex: 1; font-size: 1em; }
1549
- .step-duration { color: var(--dark-gray-color); font-size: 0.9em; }
1550
- .step-details { display: none; padding: 14px; margin-top: 8px; background: #fdfdfd; border-radius: 6px; font-size: 0.95em; border: 1px solid var(--light-gray-color); }
1551
- .step-info { margin-bottom: 8px; }
1552
- .step-error { color: var(--danger-color); margin-top: 12px; padding: 14px; background: rgba(244,67,54,0.05); border-radius: 4px; font-size: 0.95em; border-left: 3px solid var(--danger-color); }
1553
- .step-error pre.stack-trace { margin-top: 10px; padding: 12px; background-color: rgba(0,0,0,0.03); border-radius: 4px; font-size:0.9em; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
1554
- .step-hook { background-color: rgba(33,150,243,0.04); border-left: 3px solid var(--info-color) !important; }
1555
- .step-hook .step-title { font-style: italic; color: var(--info-color)}
1556
- .nested-steps { margin-top: 12px; }
1557
-
1558
- .attachments-section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--light-gray-color); }
1559
- .attachments-section h4 { margin-top: 0; margin-bottom: 20px; font-size: 1.1em; color: var(--text-color); }
1560
- .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
1561
- .attachment-item {
1562
- border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: #fff;
1563
- box-shadow: var(--box-shadow-light); overflow: hidden; display: flex; flex-direction: column;
1564
- transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
1565
- }
1566
- .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
1567
- .attachment-item img {
1568
- width: 100%; height: 180px; object-fit: cover; display: block;
1569
- border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease;
1570
- }
1571
- .attachment-item a:hover img { opacity: 0.85; }
1572
- .attachment-caption {
1573
- padding: 12px 15px; font-size: 0.9em; text-align: center;
1574
- color: var(--text-color-secondary); word-break: break-word; background-color: var(--light-gray-color);
1575
- }
1576
- .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
1577
- .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
1578
- .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;}
1579
-
1580
- .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
1581
- /* Removed D3 specific .chart-axis, .main-chart-title, .chart-line.* rules */
1582
- /* Highcharts styles its elements with classes like .highcharts-axis, .highcharts-title etc. */
1583
-
1584
- .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;}
1585
- .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
1586
- .test-history-card {
1587
- background: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius);
1588
- padding: 22px; box-shadow: var(--box-shadow-light); display: flex; flex-direction: column;
1589
- }
1590
- .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); }
1591
- .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1592
- .test-history-header p { font-weight: 500 }
1593
- .test-history-trend { margin-bottom: 20px; min-height: 110px; }
1594
- .test-history-trend div[id^="testHistoryChart-"] { /* Highcharts container for history */
1595
- display: block; margin: 0 auto; max-width:100%; height: 100px; width: 320px; /* Match JS config */
1596
- }
1597
- /* .test-history-trend .small-axis text {font-size: 11px;} Removed D3 specific */
1598
- .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
1599
- .test-history-details-collapsible summary:hover {text-decoration: underline;}
1600
- .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
1601
- .test-history-details th, .test-history-details td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--light-gray-color); }
1602
- .test-history-details th { background-color: var(--light-gray-color); font-weight: 600; }
1603
- .status-badge-small {
1604
- padding: 3px 7px; border-radius: 4px; font-size: 0.8em; font-weight: 600;
1605
- color: white; text-transform: uppercase; display: inline-block;
1606
- }
1607
- .status-badge-small.status-passed { background-color: var(--success-color); }
1608
- .status-badge-small.status-failed { background-color: var(--danger-color); }
1609
- .status-badge-small.status-skipped { background-color: var(--warning-color); }
1610
- .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
1611
-
1612
- .no-data, .no-tests, .no-steps, .no-data-chart {
1613
- padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em;
1614
- background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0;
1615
- border: 1px dashed var(--medium-gray-color);
1616
- }
1617
- .no-data-chart {font-size: 0.95em; padding: 18px;}
1618
-
1619
- #test-ai iframe { border: 1px solid var(--border-color); width: 100%; height: 85vh; border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); }
1620
- #test-ai p {margin-bottom: 18px; font-size: 1em; color: var(--text-color-secondary);}
1621
- pre .stdout-log { background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1622
- pre .stderr-log { background-color: #2d2d2d; color: indianred; padding: 1.25em; border-radius: 0.85em; line-height: 1.2; }
1623
-
1624
- .trace-preview {
1625
- padding: 1rem;
1626
- text-align: center;
1627
- background: #f5f5f5;
1628
- border-bottom: 1px solid #e1e1e1;
1629
- }
1630
-
1631
- .trace-icon {
1632
- font-size: 2rem;
1633
- display: block;
1634
- margin-bottom: 0.5rem;
1635
- }
1636
-
1637
- .trace-name {
1638
- word-break: break-word;
1639
- font-size: 0.9rem;
1640
- }
1641
-
1642
- .trace-actions {
1643
- display: flex;
1644
- gap: 0.5rem;
1645
- }
1646
-
1647
- .trace-actions a {
1648
- flex: 1;
1649
- text-align: center;
1650
- padding: 0.25rem 0.5rem;
1651
- font-size: 0.85rem;
1652
- border-radius: 4px;
1653
- text-decoration: none;
1654
- background: cornflowerblue;
1655
- color: aliceblue;
2157
+ <link rel="icon" type="image/png" href="https://i.postimg.cc/v817w4sg/logo.png">
2158
+ <link rel="apple-touch-icon" href="https://i.postimg.cc/v817w4sg/logo.png">
2159
+ <script src="https://code.highcharts.com/highcharts.js" defer></script>
2160
+ <title>Playwright Pulse Report (Static Report)</title>
2161
+
2162
+ <style>
2163
+ :root {
2164
+ --primary-color: #60a5fa; --secondary-color: #f472b6; --accent-color: #a78bfa; --accent-color-alt: #fb923c;
2165
+ --success-color: #34d399; --danger-color: #f87171; --warning-color: #fbbf24; --info-color: #60a5fa;
2166
+ --light-gray-color: #374151; --medium-gray-color: #4b5563; --dark-gray-color: #9ca3af;
2167
+ --text-color: #f9fafb; --text-color-secondary: #d1d5db; --border-color: #4b5563; --background-color: #111827;
2168
+ --card-background-color: #1f2937; --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
2169
+ --border-radius: 8px; --box-shadow: 0 5px 15px rgba(0,0,0,0.3); --box-shadow-light: 0 3px 8px rgba(0,0,0,0.2); --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.3);
1656
2170
  }
1657
-
1658
- .view-trace {
1659
- background: #3182ce;
1660
- color: white;
2171
+ .trend-chart-container, .test-history-trend div[id^="testHistoryChart-"] { min-height: 100px; }
2172
+ .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); }
2173
+ .highcharts-background { fill: transparent; }
2174
+ .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
2175
+ .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
2176
+ .highcharts-axis-title { fill: var(--text-color) !important; }
2177
+ .highcharts-tooltip > span { background-color: rgba(31,41,55,0.95) !important; border-color: rgba(31,41,55,0.95) !important; color: #f9fafb !important; padding: 10px !important; border-radius: 6px !important; }
2178
+ body { font-family: var(--font-family); margin: 0; background-color: var(--background-color); color: var(--text-color); line-height: 1.65; font-size: 16px; }
2179
+ .container { padding: 30px; border-radius: var(--border-radius); box-shadow: var(--box-shadow); background: repeating-linear-gradient(#1f2937, #374151, #1f2937); }
2180
+ .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; }
2181
+ .header-title { display: flex; align-items: center; gap: 15px; }
2182
+ .header h1 { margin: 0; font-size: 1.85em; font-weight: 600; color: var(--primary-color); }
2183
+ #report-logo { height: 40px; width: 55px; }
2184
+ .run-info { font-size: 0.9em; text-align: right; color: var(--text-color-secondary); line-height:1.5;}
2185
+ .run-info strong { color: var(--text-color); }
2186
+ .tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 30px; overflow-x: auto; }
2187
+ .tab-button { padding: 15px 25px; background: none; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 1.1em; font-weight: 600; color: var(--text-color); transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap; }
2188
+ .tab-button:hover { color: var(--accent-color); }
2189
+ .tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
2190
+ .tab-content { display: none; animation: fadeIn 0.4s ease-out; }
2191
+ .tab-content.active { display: block; }
2192
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
2193
+ .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 22px; margin-bottom: 35px; }
2194
+ .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; }
2195
+ .summary-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow); }
2196
+ .summary-card h3 { margin: 0 0 10px; font-size: 1.05em; font-weight: 500; color: var(--text-color-secondary); }
2197
+ .summary-card .value { font-size: 2.4em; font-weight: 600; margin-bottom: 8px; }
2198
+ .summary-card .trend-percentage { font-size: 1em; color: var(--dark-gray-color); }
2199
+ .status-passed .value, .stat-passed svg { color: var(--success-color); }
2200
+ .status-failed .value, .stat-failed svg { color: var(--danger-color); }
2201
+ .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
2202
+ .dashboard-bottom-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 28px; align-items: stretch; }
2203
+ .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; }
2204
+ .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); }
2205
+ .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { flex-grow: 1; min-height: 250px; }
2206
+ .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
2207
+ .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
2208
+ .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
2209
+ .status-badge-small-tooltip.status-skipped { background-color: var(--warning-color); }
2210
+ .status-badge-small-tooltip.status-unknown { background-color: var(--dark-gray-color); }
2211
+ .suites-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
2212
+ .summary-badge { background-color: var(--light-gray-color); color: var(--text-color-secondary); padding: 7px 14px; border-radius: 16px; font-size: 0.9em; }
2213
+ .suites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
2214
+ .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; }
2215
+ .suite-card:hover { box-shadow: var(--box-shadow); }
2216
+ .suite-card.status-passed { border-left-color: var(--success-color); }
2217
+ .suite-card.status-failed { border-left-color: var(--danger-color); }
2218
+ .suite-card.status-skipped { border-left-color: var(--warning-color); }
2219
+ .suite-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
2220
+ .suite-name { font-weight: 600; font-size: 1.05em; color: var(--text-color); margin-right: 10px; word-break: break-word;}
2221
+ .browser-tag { font-size: 0.8em; background-color: var(--medium-gray-color); color: var(--text-color-secondary); padding: 3px 8px; border-radius: 4px; white-space: nowrap;}
2222
+ .suite-card-body .test-count { font-size: 0.95em; color: var(--text-color-secondary); display: block; margin-bottom: 10px; }
2223
+ .suite-stats { display: flex; gap: 14px; font-size: 0.95em; align-items: center; }
2224
+ .suite-stats span { display: flex; align-items: center; gap: 6px; }
2225
+ .suite-stats svg { vertical-align: middle; font-size: 1.15em; }
2226
+ .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: 1px solid var(--border-color); }
2227
+ .filters input, .filters select, .filters button { padding: 11px 15px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 1em; background-color: var(--card-background-color); color: var(--text-color); }
2228
+ .filters input { flex-grow: 1; min-width: 240px;}
2229
+ .filters select {min-width: 180px;}
2230
+ .filters button { background-color: var(--primary-color); color: white; cursor: pointer; transition: background-color 0.2s ease, box-shadow 0.2s ease; border: none; }
2231
+ .filters button:hover { background-color: var(--accent-color); box-shadow: 0 2px 5px rgba(0,0,0,0.3);}
2232
+ .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; }
2233
+ .test-case-header { padding: 10px 15px; background-color: var(--card-background-color); cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid transparent; transition: background-color 0.2s ease; }
2234
+ .test-case-header:hover { background-color: var(--light-gray-color); }
2235
+ .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: var(--light-gray-color); }
2236
+ .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
2237
+ .test-case-title { font-weight: 600; color: var(--text-color); font-size: 1em; }
2238
+ .test-case-browser { font-size: 0.9em; color: var(--text-color-secondary); }
2239
+ .test-case-meta { display: flex; align-items: center; gap: 12px; font-size: 0.9em; color: var(--text-color-secondary); flex-shrink: 0; }
2240
+ .test-duration { background-color: var(--light-gray-color); padding: 4px 10px; border-radius: 12px; font-size: 0.9em;}
2241
+ .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.3); }
2242
+ .status-badge.status-passed { background-color: var(--success-color); }
2243
+ .status-badge.status-failed { background-color: var(--danger-color); }
2244
+ .status-badge.status-skipped { background-color: var(--warning-color); }
2245
+ .status-badge.status-unknown { background-color: var(--dark-gray-color); }
2246
+ .tag { display: inline-block; background: linear-gradient(#4b5563, #1f2937, #111827); color: #f9fafb; padding: 3px 10px; border-radius: 12px; font-size: 0.85em; margin-right: 6px; font-weight: 400; }
2247
+ .test-case-content { display: none; padding: 20px; border-top: 1px solid var(--border-color); background-color: var(--light-gray-color); }
2248
+ .test-case-content h4 { margin-top: 22px; margin-bottom: 14px; font-size: 1.15em; color: var(--primary-color); }
2249
+ .test-case-content p { margin-bottom: 10px; font-size: 1em; }
2250
+ .test-error-summary { margin-bottom: 20px; padding: 14px; background-color: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.3); border-left: 4px solid var(--danger-color); border-radius: 4px; }
2251
+ .test-error-summary h4 { color: var(--danger-color); margin-top:0;}
2252
+ .test-error-summary pre { white-space: pre-wrap; word-break: break-all; color: var(--danger-color); font-size: 0.95em;}
2253
+ .steps-list { margin: 18px 0; }
2254
+ @supports (content-visibility: auto) {
2255
+ .tab-content,
2256
+ #test-runs .test-case,
2257
+ .attachments-section,
2258
+ .test-history-card,
2259
+ .trend-chart,
2260
+ .suite-card {
2261
+ content-visibility: auto;
2262
+ contain-intrinsic-size: 1px 600px;
1661
2263
  }
1662
-
1663
- .view-trace:hover {
1664
- background: #2c5282;
1665
2264
  }
1666
-
1667
- .download-trace {
1668
- background: #e2e8f0;
1669
- color: #2d3748;
2265
+ .test-case,
2266
+ .test-history-card,
2267
+ .suite-card,
2268
+ .attachments-section {
2269
+ contain: content;
1670
2270
  }
1671
-
1672
- .download-trace:hover {
1673
- background: #cbd5e0;
2271
+ .attachments-grid .attachment-item img.lazy-load-image {
2272
+ width: 100%;
2273
+ aspect-ratio: 4 / 3;
2274
+ object-fit: cover;
1674
2275
  }
1675
-
1676
- .filters button.clear-filters-btn {
1677
- background-color: var(--medium-gray-color); /* Or any other suitable color */
1678
- color: var(--text-color);
1679
- /* Add other styling as per your .filters button style if needed */
2276
+ .attachments-grid .attachment-item.video-item {
2277
+ aspect-ratio: 16 / 9;
1680
2278
  }
1681
2279
 
1682
- .filters button.clear-filters-btn:hover {
1683
- background-color: var(--dark-gray-color); /* Darker on hover */
1684
- color: #fff;
1685
- }
1686
- @media (max-width: 1200px) {
1687
- .trend-charts-row { grid-template-columns: 1fr; }
1688
- }
1689
- @media (max-width: 992px) {
1690
- .dashboard-bottom-row { grid-template-columns: 1fr; }
1691
- .pie-chart-wrapper div[id^="pieChart-"] { max-width: 350px; margin: 0 auto; }
1692
- .filters input { min-width: 180px; }
1693
- .filters select { min-width: 150px; }
1694
- }
1695
- @media (max-width: 768px) {
1696
- body { font-size: 15px; }
1697
- .container { margin: 10px; padding: 20px; }
1698
- .header { flex-direction: column; align-items: flex-start; gap: 15px; }
1699
- .header h1 { font-size: 1.6em; }
1700
- .run-info { text-align: left; font-size:0.9em; }
1701
- .tabs { margin-bottom: 25px;}
1702
- .tab-button { padding: 12px 20px; font-size: 1.05em;}
1703
- .dashboard-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px;}
1704
- .summary-card .value {font-size: 2em;}
1705
- .summary-card h3 {font-size: 0.95em;}
1706
- .filters { flex-direction: column; padding: 18px; gap: 12px;}
1707
- .filters input, .filters select, .filters button {width: 100%; box-sizing: border-box;}
1708
- .test-case-header { flex-direction: column; align-items: flex-start; gap: 10px; padding: 14px; }
1709
- .test-case-summary {gap: 10px;}
1710
- .test-case-title {font-size: 1.05em;}
1711
- .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;}
1712
- .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;}
1713
- .test-history-grid {grid-template-columns: 1fr;}
1714
- .pie-chart-wrapper {min-height: auto;}
1715
- }
1716
- @media (max-width: 480px) {
1717
- body {font-size: 14px;}
1718
- .container {padding: 15px;}
1719
- .header h1 {font-size: 1.4em;}
1720
- #report-logo { height: 35px; width: 35px; }
1721
- .tab-button {padding: 10px 15px; font-size: 1em;}
1722
- .summary-card .value {font-size: 1.8em;}
1723
- .attachments-grid {grid-template-columns: 1fr;}
1724
- .step-item {padding-left: calc(var(--depth, 0) * 18px);}
1725
- .test-case-content, .step-details {padding: 15px;}
1726
- .trend-charts-row {gap: 20px;}
1727
- .trend-chart {padding: 20px;}
1728
- }
1729
- </style>
2280
+ .step-item { margin-bottom: 8px; padding-left: calc(var(--depth, 0) * 28px); }
2281
+ .step-header { display: flex; align-items: center; cursor: pointer; padding: 10px 14px; border-radius: 6px; background-color: var(--card-background-color); border: 1px solid var(--light-gray-color); transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; }
2282
+ .step-header:hover { background-color: var(--light-gray-color); border-color: var(--medium-gray-color); box-shadow: var(--box-shadow-inset); }
2283
+ .step-icon { margin-right: 12px; width: 20px; text-align: center; font-size: 1.1em; }
2284
+ .step-title { flex: 1; font-size: 1em; }
2285
+ .step-duration { color: var(--dark-gray-color); font-size: 0.9em; }
2286
+ .step-details { display: none; padding: 14px; margin-top: 8px; background: var(--light-gray-color); border-radius: 6px; font-size: 0.95em; border: 1px solid var(--light-gray-color); }
2287
+ .step-info { margin-bottom: 8px; }
2288
+ .test-error-summary { color: var(--danger-color); margin-top: 12px; padding: 14px; background: rgba(248,113,113,0.1); border-radius: 4px; font-size: 0.95em; border-left: 3px solid var(--danger-color); }
2289
+ .test-error-summary pre.stack-trace { margin-top: 10px; padding: 12px; background-color: rgba(0,0,0,0.2); border-radius: 4px; font-size:0.9em; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
2290
+ .step-hook { background-color: rgba(96,165,250,0.1); border-left: 3px solid var(--info-color) !important; }
2291
+ .step-hook .step-title { font-style: italic; color: var(--info-color)}
2292
+ .nested-steps { margin-top: 12px; }
2293
+ .attachments-section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--light-gray-color); }
2294
+ .attachments-section h4 { margin-top: 0; margin-bottom: 20px; font-size: 1.1em; color: var(--text-color); }
2295
+ .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
2296
+ .attachment-item { border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: var(--card-background-color); box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; }
2297
+ .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
2298
+ .attachment-item img, .attachment-item video { width: 100%; height: 180px; object-fit: cover; display: block; background-color: var(--medium-gray-color); border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease; }
2299
+ .attachment-info { padding: 12px; margin-top: auto; background-color: var(--light-gray-color);}
2300
+ .attachment-item a:hover img { opacity: 0.85; }
2301
+ .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); }
2302
+ .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
2303
+ .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
2304
+ .code-section pre { background-color: #111827; color: #f9fafb; 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;}
2305
+ .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
2306
+ .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;}
2307
+ .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
2308
+ .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; }
2309
+ .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); }
2310
+ .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2311
+ .test-history-header p { font-weight: 500 }
2312
+ .test-history-trend { margin-bottom: 20px; min-height: 110px; }
2313
+ .test-history-trend div[id^="testHistoryChart-"] { display: block; margin: 0 auto; max-width:100%; height: 100px; width: 320px; }
2314
+ .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
2315
+ .test-history-details-collapsible summary:hover {text-decoration: underline;}
2316
+ .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
2317
+ .test-history-details th, .test-history-details td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--light-gray-color); }
2318
+ .test-history-details th { background-color: var(--light-gray-color); font-weight: 600; }
2319
+ .status-badge-small { padding: 3px 7px; border-radius: 4px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase; display: inline-block; }
2320
+ .status-badge-small.status-passed { background-color: var(--success-color); }
2321
+ .status-badge-small.status-failed { background-color: var(--danger-color); }
2322
+ .status-badge-small.status-skipped { background-color: var(--warning-color); }
2323
+ .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
2324
+ .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); }
2325
+ .no-data-chart {font-size: 0.95em; padding: 18px;}
2326
+ .ai-failure-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 22px; }
2327
+ .ai-failure-card { background: var(--card-background-color); border: 1px solid var(--border-color); border-left: 5px solid var(--danger-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; }
2328
+ .ai-failure-card-header { padding: 15px 20px; border-bottom: 1px solid var(--light-gray-color); display: flex; align-items: center; justify-content: space-between; gap: 15px; }
2329
+ .ai-failure-card-header h3 { margin: 0; font-size: 1.1em; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2330
+ .ai-failure-card-body { padding: 20px; }
2331
+ .ai-fix-btn { background-color: var(--primary-color); color: white; border: none; padding: 10px 18px; font-size: 1em; font-weight: 600; border-radius: 6px; cursor: pointer; transition: background-color 0.2s ease, transform 0.2s ease; display: inline-flex; align-items: center; gap: 8px; }
2332
+ .ai-fix-btn:hover { background-color: var(--accent-color); transform: translateY(-2px); }
2333
+ .ai-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 1050; animation: fadeIn 0.3s; }
2334
+ .ai-modal-content { background-color: var(--card-background-color); color: var(--text-color); border-radius: var(--border-radius); width: 90%; max-width: 800px; max-height: 90vh; box-shadow: 0 10px 30px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; }
2335
+ .ai-modal-header { padding: 18px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
2336
+ .ai-modal-header h3 { margin: 0; font-size: 1.25em; }
2337
+ .ai-modal-close { font-size: 2rem; font-weight: 300; cursor: pointer; color: var(--dark-gray-color); line-height: 1; transition: color 0.2s; }
2338
+ .ai-modal-close:hover { color: var(--danger-color); }
2339
+ .ai-modal-body { padding: 25px; overflow-y: auto; }
2340
+ .ai-modal-body h4 { margin-top: 18px; margin-bottom: 10px; font-size: 1.1em; color: var(--primary-color); }
2341
+ .ai-modal-body p { margin-bottom: 15px; }
2342
+ .ai-loader { margin: 40px auto; border: 5px solid var(--medium-gray-color); border-top: 5px solid var(--primary-color); border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; }
2343
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
2344
+ .trace-preview { padding: 1rem; text-align: center; background: var(--light-gray-color); border-bottom: 1px solid var(--border-color); }
2345
+ .trace-icon { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
2346
+ .trace-name { word-break: break-word; font-size: 0.9rem; }
2347
+ .trace-actions { display: flex; gap: 0.5rem; }
2348
+ .trace-actions a { flex: 1; text-align: center; padding: 0.25rem 0.5rem; font-size: 0.85rem; border-radius: 4px; text-decoration: none; background: var(--primary-color); color: white; }
2349
+ .view-trace { background: var(--primary-color); color: white; }
2350
+ .view-trace:hover { background: var(--accent-color); }
2351
+ .download-trace { background: var(--medium-gray-color); color: var(--text-color); }
2352
+ .download-trace:hover { background: var(--dark-gray-color); }
2353
+ .filters button.clear-filters-btn { background-color: var(--medium-gray-color); color: var(--text-color); }
2354
+ .filters button.clear-filters-btn:hover { background-color: var(--dark-gray-color); color: #fff; }
2355
+ .copy-btn {color: var(--primary-color); background: var(--card-background-color); border-radius: 8px; cursor: pointer; border-color: var(--primary-color); font-size: 1em; margin-left: 93%; font-weight: 600;}
2356
+ .ai-analyzer-stats { display: flex; gap: 20px; margin-bottom: 25px; padding: 20px; background: linear-gradient(135deg, #374151 0%, #1f2937 100%); border-radius: var(--border-radius); justify-content: center; }
2357
+ .stat-item { text-align: center; color: white; }
2358
+ .stat-number { display: block; font-size: 2em; font-weight: 700; line-height: 1;}
2359
+ .stat-label { font-size: 0.9em; opacity: 0.9; font-weight: 500;}
2360
+ .ai-analyzer-description { margin-bottom: 25px; font-size: 1em; color: var(--text-color-secondary); text-align: center; max-width: 600px; margin-left: auto; margin-right: auto;}
2361
+ .compact-failure-list { display: flex; flex-direction: column; gap: 15px; }
2362
+ .compact-failure-item { background: var(--card-background-color); border: 1px solid var(--border-color); border-left: 4px solid var(--danger-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease;}
2363
+ .compact-failure-item:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
2364
+ .failure-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px; gap: 15px;}
2365
+ .failure-main-info { flex: 1; min-width: 0; }
2366
+ .failure-title { margin: 0 0 8px 0; font-size: 1.1em; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
2367
+ .failure-meta { display: flex; gap: 12px; align-items: center;}
2368
+ .browser-indicator, .duration-indicator { font-size: 0.85em; padding: 3px 8px; border-radius: 12px; font-weight: 500;}
2369
+ .browser-indicator { background: var(--info-color); color: white; }
2370
+ #load-more-tests { font-size: 16px; padding: 4px; background-color: var(--light-gray-color); border-radius: 4px; color: var(--text-color); }
2371
+ .duration-indicator { background: var(--medium-gray-color); color: var(--text-color); }
2372
+ .compact-ai-btn { background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; border: none; padding: 12px 18px; border-radius: 6px; cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 8px; transition: all 0.3s ease; white-space: nowrap;}
2373
+ .compact-ai-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(55, 65, 81, 0.4); }
2374
+ .ai-text { font-size: 0.95em; }
2375
+ .failure-error-preview { padding: 0 20px 18px 20px; border-top: 1px solid var(--light-gray-color);}
2376
+ .error-snippet { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); border-radius: 6px; padding: 12px; margin-bottom: 12px; font-family: monospace; font-size: 0.9em; color: var(--danger-color); line-height: 1.4;}
2377
+ .expand-error-btn { background: none; border: 1px solid var(--border-color); color: var(--text-color-secondary); padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease;}
2378
+ .expand-error-btn:hover { background: var(--light-gray-color); border-color: var(--medium-gray-color); }
2379
+ .expand-icon { transition: transform 0.2s ease; font-size: 0.8em;}
2380
+ .expand-error-btn.expanded .expand-icon { transform: rotate(180deg); }
2381
+ .full-error-details { padding: 0 20px 20px 20px; border-top: 1px solid var(--light-gray-color); margin-top: 0;}
2382
+ .full-error-content { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); border-radius: 6px; padding: 15px; font-family: monospace; font-size: 0.9em; color: var(--danger-color); line-height: 1.4; max-height: 300px; overflow-y: auto;}
2383
+ @media (max-width: 1200px) { .trend-charts-row { grid-template-columns: 1fr; } }
2384
+ @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; } }
2385
+ @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;} .ai-failure-cards-grid { grid-template-columns: 1fr; } .ai-analyzer-stats { flex-direction: column; gap: 15px; text-align: center; } .failure-header { flex-direction: column; align-items: stretch; gap: 15px; } .failure-main-info { text-align: center; } .failure-meta { justify-content: center; } .compact-ai-btn { justify-content: center; padding: 12px 20px; } }
2386
+ @media (max-width: 480px) { body {font-size: 14px;} .container {padding: 15px;} .header h1 {font-size: 1.4em;} #report-logo { height: 35px; width: 45px; } .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;} .stat-item .stat-number { font-size: 1.5em; } .failure-header { padding: 15px; } .failure-error-preview, .full-error-details { padding-left: 15px; padding-right: 15px; } }
2387
+ .trace-actions a { text-decoration: none; color: var(--primary-color); font-weight: 500; font-size: 0.9em; }
2388
+ .generic-attachment { text-align: center; padding: 1rem; justify-content: center; }
2389
+ .attachment-icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
2390
+ .attachment-caption { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; }
2391
+ .attachment-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
2392
+ .attachment-type { font-size: 0.8rem; color: var(--text-color-secondary); }
2393
+ .footer-text { color: white }
2394
+ </style>
1730
2395
  </head>
1731
2396
  <body>
1732
2397
  <div class="container">
1733
2398
  <header class="header">
1734
2399
  <div class="header-title">
1735
- <img id="report-logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA3bDggNSA4LTUtOC01eiIgZmlsbD0iIzNmNTFiNSIvPjxwYXRoIGQ9Ik0xMiA2TDQgMTFsOCA1IDgtNS04LTV6IiBmaWxsPSIjNDI4NWY0Ii8+PHBhdGggZD0iTTEyIDEwbC04IDUgOCA1IDgtNS04LTV6IiBmaWxsPSIjM2Q1NWI0Ii8+PC9zdmc+" alt="Report Logo">
2400
+ <img id="report-logo" src="https://i.postimg.cc/v817w4sg/logo.png" alt="Report Logo">
1736
2401
  <h1>Playwright Pulse Report</h1>
1737
2402
  </div>
1738
- <div class="run-info">
1739
- <strong>Run Date:</strong> ${formatDate(
1740
- runSummary.timestamp
1741
- )}<br>
1742
- <strong>Total Duration:</strong> ${formatDuration(
1743
- runSummary.duration
1744
- )}
1745
- </div>
2403
+ <div class="run-info"><strong>Run Date:</strong> ${formatDate(
2404
+ runSummary.timestamp
2405
+ )}<br><strong>Total Duration:</strong> ${formatDuration(
2406
+ runSummary.duration
2407
+ )}</div>
1746
2408
  </header>
1747
-
1748
2409
  <div class="tabs">
1749
2410
  <button class="tab-button active" data-tab="dashboard">Dashboard</button>
1750
2411
  <button class="tab-button" data-tab="test-runs">Test Run Summary</button>
1751
2412
  <button class="tab-button" data-tab="test-history">Test History</button>
1752
- <button class="tab-button" data-tab="test-ai">AI Analysis</button>
2413
+ <button class="tab-button" data-tab="ai-failure-analyzer">AI Failure Analyzer</button>
1753
2414
  </div>
1754
-
1755
2415
  <div id="dashboard" class="tab-content active">
1756
2416
  <div class="dashboard-grid">
1757
- <div class="summary-card">
1758
- <h3>Total Tests</h3><div class="value">${
1759
- runSummary.totalTests
1760
- }</div>
1761
- </div>
1762
- <div class="summary-card status-passed">
1763
- <h3>Passed</h3><div class="value">${runSummary.passed}</div>
1764
- <div class="trend-percentage">${passPercentage}%</div>
1765
- </div>
1766
- <div class="summary-card status-failed">
1767
- <h3>Failed</h3><div class="value">${runSummary.failed}</div>
1768
- <div class="trend-percentage">${failPercentage}%</div>
1769
- </div>
1770
- <div class="summary-card status-skipped">
1771
- <h3>Skipped</h3><div class="value">${
1772
- runSummary.skipped || 0
1773
- }</div>
1774
- <div class="trend-percentage">${skipPercentage}%</div>
1775
- </div>
1776
- <div class="summary-card">
1777
- <h3>Avg. Test Time</h3><div class="value">${avgTestDuration}</div>
1778
- </div>
1779
- <div class="summary-card">
1780
- <h3>Run Duration</h3><div class="value">${formatDuration(
1781
- runSummary.duration
1782
- )}</div>
1783
- </div>
2417
+ <div class="summary-card"><h3>Total Tests</h3><div class="value">${
2418
+ runSummary.totalTests
2419
+ }</div></div>
2420
+ <div class="summary-card status-passed"><h3>Passed</h3><div class="value">${
2421
+ runSummary.passed
2422
+ }</div><div class="trend-percentage">${passPercentage}%</div></div>
2423
+ <div class="summary-card status-failed"><h3>Failed</h3><div class="value">${
2424
+ runSummary.failed
2425
+ }</div><div class="trend-percentage">${failPercentage}%</div></div>
2426
+ <div class="summary-card status-skipped"><h3>Skipped</h3><div class="value">${
2427
+ runSummary.skipped || 0
2428
+ }</div><div class="trend-percentage">${skipPercentage}%</div></div>
2429
+ <div class="summary-card"><h3>Avg. Test Time</h3><div class="value">${avgTestDuration}</div></div>
2430
+ <div class="summary-card"><h3>Run Duration</h3><div class="value">${formatDuration(
2431
+ runSummary.duration
2432
+ )}</div></div>
1784
2433
  </div>
1785
2434
  <div class="dashboard-bottom-row">
2435
+ <div style="display: grid; gap: 20px">
1786
2436
  ${generatePieChart(
1787
- // Changed from generatePieChartD3
1788
2437
  [
1789
2438
  { label: "Passed", value: runSummary.passed },
1790
2439
  { label: "Failed", value: runSummary.failed },
1791
2440
  { label: "Skipped", value: runSummary.skipped || 0 },
1792
2441
  ],
1793
- 400, // Default width
1794
- 390 // Default height (adjusted for legend + title)
2442
+ 400,
2443
+ 390
1795
2444
  )}
2445
+ ${
2446
+ runSummary.environment &&
2447
+ Object.keys(runSummary.environment).length > 0
2448
+ ? generateEnvironmentDashboard(runSummary.environment)
2449
+ : '<div class="no-data">Environment data not available.</div>'
2450
+ }
2451
+ </div>
1796
2452
  ${generateSuitesWidget(suitesData)}
1797
2453
  </div>
1798
2454
  </div>
1799
-
1800
2455
  <div id="test-runs" class="tab-content">
1801
2456
  <div class="filters">
1802
- <input type="text" id="filter-name" placeholder="Filter by test name/path..." style="border-color: black; border-style: outset;">
1803
- <select id="filter-status">
1804
- <option value="">All Statuses</option>
1805
- <option value="passed">Passed</option>
1806
- <option value="failed">Failed</option>
1807
- <option value="skipped">Skipped</option>
1808
- </select>
1809
- <select id="filter-browser">
1810
- <option value="">All Browsers</option>
1811
- {/* Dynamically generated options will be here */}
1812
- ${Array.from(
1813
- new Set((results || []).map((test) => test.browser || "unknown"))
1814
- )
1815
- .map(
1816
- (browser) =>
1817
- `<option value="${sanitizeHTML(browser)}">${sanitizeHTML(
1818
- browser
1819
- )}</option>`
1820
- )
1821
- .join("")}
1822
- </select>
1823
- <button id="expand-all-tests">Expand All</button>
1824
- <button id="collapse-all-tests">Collapse All</button>
1825
- <button id="clear-run-summary-filters" class="clear-filters-btn">Clear Filters</button>
1826
- </div>
1827
- <div class="test-cases-list">
1828
- ${generateTestCasesHTML()}
2457
+ <input type="text" id="filter-name" placeholder="Filter by test name/path..." style="border-color: black; border-style: outset;">
2458
+ <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>
2459
+ <select id="filter-browser"><option value="">All Browsers</option>${Array.from(
2460
+ new Set(
2461
+ (results || []).map((test) => test.browser || "unknown")
2462
+ )
2463
+ )
2464
+ .map(
2465
+ (browser) =>
2466
+ `<option value="${sanitizeHTML(browser)}">${sanitizeHTML(
2467
+ browser
2468
+ )}</option>`
2469
+ )
2470
+ .join("")}</select>
2471
+ <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>
1829
2472
  </div>
2473
+ <div class="test-cases-list">${generateTestCasesHTML(
2474
+ results.slice(0, 50),
2475
+ 0
2476
+ )}</div>
2477
+ ${
2478
+ results.length > 50
2479
+ ? `<div class="load-more-wrapper"><button id="load-more-tests">Load more</button></div><script type="application/json" id="remaining-tests-b64">${Buffer.from(
2480
+ generateTestCasesHTML(results.slice(50), 50),
2481
+ "utf8"
2482
+ ).toString("base64")}</script>`
2483
+ : ``
2484
+ }
1830
2485
  </div>
1831
-
1832
2486
  <div id="test-history" class="tab-content">
1833
2487
  <h2 class="tab-main-title">Execution Trends</h2>
1834
2488
  <div class="trend-charts-row">
1835
- <div class="trend-chart">
1836
- <h3 class="chart-title-header">Test Volume & Outcome Trends</h3>
2489
+ <div class="trend-chart"><h3 class="chart-title-header">Test Volume & Outcome Trends</h3>
1837
2490
  ${
1838
2491
  trendData && trendData.overall && trendData.overall.length > 0
1839
2492
  ? generateTestTrendsChart(trendData)
1840
2493
  : '<div class="no-data">Overall trend data not available for test counts.</div>'
1841
2494
  }
1842
2495
  </div>
1843
- <div class="trend-chart">
1844
- <h3 class="chart-title-header">Execution Duration Trends</h3>
2496
+ <div class="trend-chart"><h3 class="chart-title-header">Execution Duration Trends</h3>
1845
2497
  ${
1846
2498
  trendData && trendData.overall && trendData.overall.length > 0
1847
2499
  ? generateDurationTrendChart(trendData)
1848
2500
  : '<div class="no-data">Overall trend data not available for durations.</div>'
1849
2501
  }
2502
+ </div>
2503
+ </div>
2504
+ <h2 class="tab-main-title">Test Distribution by Worker ${infoTooltip}</h2>
2505
+ <div class="trend-charts-row">
2506
+ <div class="trend-chart">
2507
+ ${generateWorkerDistributionChart(results)}
1850
2508
  </div>
1851
2509
  </div>
1852
2510
  <h2 class="tab-main-title">Individual Test History</h2>
@@ -1858,69 +2516,162 @@ function generateHTML(reportData, trendData = null) {
1858
2516
  : '<div class="no-data">Individual test history data not available.</div>'
1859
2517
  }
1860
2518
  </div>
1861
-
1862
- <div id="test-ai" class="tab-content">
1863
- <iframe
1864
- src="https://ai-test-analyser.netlify.app/"
1865
- width="100%"
1866
- height="100%"
1867
- frameborder="0"
1868
- allowfullscreen
1869
- style="border: none; height: 100vh;">
1870
- </iframe>
2519
+ <div id="ai-failure-analyzer" class="tab-content">
2520
+ ${generateAIFailureAnalyzerTab(results)}
1871
2521
  </div>
1872
- <footer style="
1873
- padding: 0.5rem;
1874
- box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
1875
- text-align: center;
1876
- font-family: 'Segoe UI', system-ui, sans-serif;
1877
- ">
1878
- <div style="
1879
- display: inline-flex;
1880
- align-items: center;
1881
- gap: 0.5rem;
1882
- color: #333;
1883
- font-size: 0.9rem;
1884
- font-weight: 600;
1885
- letter-spacing: 0.5px;
1886
- ">
1887
- <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"/>
1888
- <span>Created by</span>
1889
- <a href="https://github.com/Arghajit47"
1890
- target="_blank"
1891
- rel="noopener noreferrer"
1892
- style="
1893
- color: #7737BF;
1894
- font-weight: 700;
1895
- font-style: italic;
1896
- text-decoration: none;
1897
- transition: all 0.2s ease;
1898
- "
1899
- onmouseover="this.style.color='#BF5C37'"
1900
- onmouseout="this.style.color='#7737BF'">
1901
- Arghajit Singha
1902
- </a>
1903
- </div>
1904
- <div style="
1905
- margin-top: 0.5rem;
1906
- font-size: 0.75rem;
1907
- color: #666;
1908
- ">
1909
- Crafted with precision
1910
- </div>
1911
- </footer>
2522
+ <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;">
2523
+ <div style="display: inline-flex; align-items: center; gap: 0.5rem; color: #333; font-size: 0.9rem; font-weight: 600; letter-spacing: 0.5px;">
2524
+ <span class="footer-text">Created by</span>
2525
+ <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>
2526
+ </div>
2527
+ <div style="margin-top: 0.5rem; font-size: 0.75rem; color: #666;">Crafted with precision</div>
2528
+ </footer>
1912
2529
  </div>
1913
-
1914
-
1915
2530
  <script>
1916
2531
  // Ensure formatDuration is globally available
1917
- if (typeof formatDuration === 'undefined') {
1918
- function formatDuration(ms) {
1919
- if (ms === undefined || ms === null || ms < 0) return "0.0s";
1920
- return (ms / 1000).toFixed(1) + "s";
2532
+ if (typeof formatDuration === 'undefined') {
2533
+ function formatDuration(ms) {
2534
+ if (ms === undefined || ms === null || ms < 0) return "0.0s";
2535
+ return (ms / 1000).toFixed(1) + "s";
2536
+ }
2537
+ }
2538
+ function copyLogContent(elementId, button) {
2539
+ const logElement = document.getElementById(elementId);
2540
+ if (!logElement) {
2541
+ console.error('Could not find log element with ID:', elementId);
2542
+ return;
2543
+ }
2544
+ navigator.clipboard.writeText(logElement.innerText).then(() => {
2545
+ button.textContent = 'Copied!';
2546
+ setTimeout(() => { button.textContent = 'Copy'; }, 2000);
2547
+ }).catch(err => {
2548
+ console.error('Failed to copy log content:', err);
2549
+ button.textContent = 'Failed';
2550
+ setTimeout(() => { button.textContent = 'Copy'; }, 2000);
2551
+ });
2552
+ }
2553
+
2554
+ function getAIFix(button) {
2555
+ const modal = document.getElementById('ai-fix-modal');
2556
+ const modalContent = document.getElementById('ai-fix-modal-content');
2557
+ const modalTitle = document.getElementById('ai-fix-modal-title');
2558
+
2559
+ modal.style.display = 'flex';
2560
+ document.body.style.setProperty('overflow', 'hidden', 'important');
2561
+ modalTitle.textContent = 'Analyzing...';
2562
+ modalContent.innerHTML = '<div class="ai-loader"></div>';
2563
+
2564
+ try {
2565
+ const testJson = button.dataset.testJson;
2566
+ const test = JSON.parse(atob(testJson));
2567
+
2568
+ const testName = test.name || 'Unknown Test';
2569
+ const failureLogsAndErrors = [
2570
+ 'Error Message:',
2571
+ test.errorMessage || 'Not available.',
2572
+ '\\n\\n--- stdout ---',
2573
+ (test.stdout && test.stdout.length > 0) ? test.stdout.join('\\n') : 'Not available.',
2574
+ '\\n\\n--- stderr ---',
2575
+ (test.stderr && test.stderr.length > 0) ? test.stderr.join('\\n') : 'Not available.'
2576
+ ].join('\\n');
2577
+ const codeSnippet = test.snippet || '';
2578
+
2579
+ const shortTestName = testName.split(' > ').pop();
2580
+ modalTitle.textContent = \`Analysis for: \${shortTestName}\`;
2581
+
2582
+ const apiUrl = 'https://ai-test-analyser.netlify.app/api/analyze';
2583
+ fetch(apiUrl, {
2584
+ method: 'POST',
2585
+ headers: { 'Content-Type': 'application/json' },
2586
+ body: JSON.stringify({
2587
+ testName: testName,
2588
+ failureLogsAndErrors: failureLogsAndErrors,
2589
+ codeSnippet: codeSnippet,
2590
+ }),
2591
+ })
2592
+ .then(response => {
2593
+ if (!response.ok) {
2594
+ return response.text().then(text => {
2595
+ throw new Error(\`API request failed with status \${response.status}: \${text || response.statusText}\`);
2596
+ });
2597
+ }
2598
+ return response.text();
2599
+ })
2600
+ .then(text => {
2601
+ if (!text) {
2602
+ throw new Error("The AI analyzer returned an empty response. This might happen during high load or if the request was blocked. Please try again in a moment.");
2603
+ }
2604
+ try {
2605
+ return JSON.parse(text);
2606
+ } catch (e) {
2607
+ console.error("Failed to parse JSON:", text);
2608
+ throw new Error(\`The AI analyzer returned an invalid response. \${e.message}\`);
2609
+ }
2610
+ })
2611
+ .then(data => {
2612
+ const escapeHtml = (unsafe) => {
2613
+ if (typeof unsafe !== 'string') return '';
2614
+ return unsafe
2615
+ .replace(/&/g, "&amp;")
2616
+ .replace(/</g, "&lt;")
2617
+ .replace(/>/g, "&gt;")
2618
+ .replace(/"/g, "&quot;")
2619
+ .replace(/'/g, "&#039;");
2620
+ };
2621
+
2622
+ const analysisHtml = \`<h4>Analysis</h4><p>\${escapeHtml(data.rootCause) || 'No analysis provided.'}</p>\`;
2623
+
2624
+ let suggestionsHtml = '<h4>Suggestions</h4>';
2625
+ if (data.suggestedFixes && data.suggestedFixes.length > 0) {
2626
+ suggestionsHtml += '<div class="suggestions-list" style="margin-top: 15px;">';
2627
+ data.suggestedFixes.forEach(fix => {
2628
+ suggestionsHtml += \`
2629
+ <div class="suggestion-item" style="margin-bottom: 22px; border-left: 3px solid var(--accent-color-alt); padding-left: 15px;">
2630
+ <p style="margin: 0 0 8px 0; font-weight: 500;">\${escapeHtml(fix.description)}</p>
2631
+ \${fix.codeSnippet ? \`<div class="code-section"><pre><code>\${escapeHtml(fix.codeSnippet)}</code></pre></div>\` : ''}
2632
+ </div>
2633
+ \`;
2634
+ });
2635
+ suggestionsHtml += '</div>';
2636
+ } else {
2637
+ suggestionsHtml += \`<div class="code-section"><pre><code>No suggestion provided.</code></pre></div>\`;
2638
+ }
2639
+
2640
+ modalContent.innerHTML = analysisHtml + suggestionsHtml;
2641
+ })
2642
+ .catch(err => {
2643
+ console.error('AI Fix Error:', err);
2644
+ modalContent.innerHTML = \`<div class="test-error-summary"><strong>Error:</strong> Failed to get AI analysis. Please check the console for details. <br><br> \${err.message}</div>\`;
2645
+ });
2646
+
2647
+ } catch (e) {
2648
+ console.error('Error processing test data for AI Fix:', e);
2649
+ modalTitle.textContent = 'Error';
2650
+ modalContent.innerHTML = \`<div class="test-error-summary">Could not process test data. Is it formatted correctly?</div>\`;
1921
2651
  }
1922
2652
  }
1923
2653
 
2654
+ function closeAiModal() {
2655
+ const modal = document.getElementById('ai-fix-modal');
2656
+ if(modal) modal.style.display = 'none';
2657
+ document.body.style.setProperty('overflow', '', 'important');
2658
+ }
2659
+
2660
+ function toggleErrorDetails(button) {
2661
+ const errorDetails = button.closest('.compact-failure-item').querySelector('.full-error-details');
2662
+ const expandText = button.querySelector('.expand-text');
2663
+
2664
+ if (errorDetails.style.display === 'none' || !errorDetails.style.display) {
2665
+ errorDetails.style.display = 'block';
2666
+ expandText.textContent = 'Hide Full Error';
2667
+ button.classList.add('expanded');
2668
+ } else {
2669
+ errorDetails.style.display = 'none';
2670
+ expandText.textContent = 'Show Full Error';
2671
+ button.classList.remove('expanded');
2672
+ }
2673
+ }
2674
+
1924
2675
  function initializeReportInteractivity() {
1925
2676
  const tabButtons = document.querySelectorAll('.tab-button');
1926
2677
  const tabContents = document.querySelectorAll('.tab-content');
@@ -1931,82 +2682,100 @@ function generateHTML(reportData, trendData = null) {
1931
2682
  button.classList.add('active');
1932
2683
  const tabId = button.getAttribute('data-tab');
1933
2684
  const activeContent = document.getElementById(tabId);
1934
- if (activeContent) activeContent.classList.add('active');
2685
+ if (activeContent) {
2686
+ activeContent.classList.add('active');
2687
+ }
1935
2688
  });
1936
2689
  });
1937
-
1938
2690
  // --- Test Run Summary Filters ---
1939
2691
  const nameFilter = document.getElementById('filter-name');
2692
+ function ensureAllTestsAppended() {
2693
+ const node = document.getElementById('remaining-tests-b64');
2694
+ const loadMoreBtn = document.getElementById('load-more-tests');
2695
+ if (!node) return;
2696
+ const b64 = (node.textContent || '').trim();
2697
+ function b64ToUtf8(b64Str) {
2698
+ try { return decodeURIComponent(escape(window.atob(b64Str))); }
2699
+ catch (e) { return window.atob(b64Str); }
2700
+ }
2701
+ const html = b64ToUtf8(b64);
2702
+ const container = document.querySelector('#test-runs .test-cases-list');
2703
+ if (container) container.insertAdjacentHTML('beforeend', html);
2704
+ if (loadMoreBtn) loadMoreBtn.remove();
2705
+ node.remove();
2706
+ }
2707
+ const loadMoreBtn = document.getElementById('load-more-tests');
2708
+ if (loadMoreBtn) {
2709
+ loadMoreBtn.addEventListener('click', () => {
2710
+ const node = document.getElementById('remaining-tests-b64');
2711
+ if (!node) return;
2712
+ const b64 = (node.textContent || '').trim();
2713
+ function b64ToUtf8(b64Str) {
2714
+ try { return decodeURIComponent(escape(window.atob(b64Str))); }
2715
+ catch (e) { return window.atob(b64Str); }
2716
+ }
2717
+ const html = b64ToUtf8(b64);
2718
+ const container = document.querySelector('#test-runs .test-cases-list');
2719
+ if (container) container.insertAdjacentHTML('beforeend', html);
2720
+ loadMoreBtn.remove();
2721
+ node.remove();
2722
+ });
2723
+ }
2724
+
2725
+
2726
+
1940
2727
  const statusFilter = document.getElementById('filter-status');
1941
2728
  const browserFilter = document.getElementById('filter-browser');
1942
- const clearRunSummaryFiltersBtn = document.getElementById('clear-run-summary-filters'); // Get the new button
1943
-
1944
- function filterTestCases() {
2729
+ const clearRunSummaryFiltersBtn = document.getElementById('clear-run-summary-filters');
2730
+ function filterTestCases() {
2731
+ ensureAllTestsAppended();
1945
2732
  const nameValue = nameFilter ? nameFilter.value.toLowerCase() : "";
1946
2733
  const statusValue = statusFilter ? statusFilter.value : "";
1947
2734
  const browserValue = browserFilter ? browserFilter.value : "";
1948
-
1949
2735
  document.querySelectorAll('#test-runs .test-case').forEach(testCaseElement => {
1950
2736
  const titleElement = testCaseElement.querySelector('.test-case-title');
1951
2737
  const fullTestName = titleElement ? titleElement.getAttribute('title').toLowerCase() : "";
1952
2738
  const status = testCaseElement.getAttribute('data-status');
1953
2739
  const browser = testCaseElement.getAttribute('data-browser');
1954
-
1955
2740
  const nameMatch = fullTestName.includes(nameValue);
1956
2741
  const statusMatch = !statusValue || status === statusValue;
1957
2742
  const browserMatch = !browserValue || browser === browserValue;
1958
-
1959
2743
  testCaseElement.style.display = (nameMatch && statusMatch && browserMatch) ? '' : 'none';
1960
2744
  });
1961
2745
  }
1962
2746
  if(nameFilter) nameFilter.addEventListener('input', filterTestCases);
1963
2747
  if(statusFilter) statusFilter.addEventListener('change', filterTestCases);
1964
2748
  if(browserFilter) browserFilter.addEventListener('change', filterTestCases);
1965
-
1966
- // Event listener for clearing Test Run Summary filters
1967
- if (clearRunSummaryFiltersBtn) {
1968
- clearRunSummaryFiltersBtn.addEventListener('click', () => {
1969
- if (nameFilter) nameFilter.value = '';
1970
- if (statusFilter) statusFilter.value = '';
1971
- if (browserFilter) browserFilter.value = '';
1972
- filterTestCases(); // Re-apply filters (which will show all)
1973
- });
1974
- }
1975
-
2749
+ if(clearRunSummaryFiltersBtn) clearRunSummaryFiltersBtn.addEventListener('click', () => {
2750
+ ensureAllTestsAppended();
2751
+ if(nameFilter) nameFilter.value = '';
2752
+ if(statusFilter) statusFilter.value = '';
2753
+ if(browserFilter) browserFilter.value = '';
2754
+ filterTestCases();
2755
+ });
1976
2756
  // --- Test History Filters ---
1977
2757
  const historyNameFilter = document.getElementById('history-filter-name');
1978
2758
  const historyStatusFilter = document.getElementById('history-filter-status');
1979
- const clearHistoryFiltersBtn = document.getElementById('clear-history-filters'); // Get the new button
1980
-
1981
-
1982
- function filterTestHistoryCards() {
2759
+ const clearHistoryFiltersBtn = document.getElementById('clear-history-filters');
2760
+ function filterTestHistoryCards() {
1983
2761
  const nameValue = historyNameFilter ? historyNameFilter.value.toLowerCase() : "";
1984
2762
  const statusValue = historyStatusFilter ? historyStatusFilter.value : "";
1985
-
1986
2763
  document.querySelectorAll('.test-history-card').forEach(card => {
1987
2764
  const testTitle = card.getAttribute('data-test-name').toLowerCase();
1988
2765
  const latestStatus = card.getAttribute('data-latest-status');
1989
-
1990
2766
  const nameMatch = testTitle.includes(nameValue);
1991
2767
  const statusMatch = !statusValue || latestStatus === statusValue;
1992
-
1993
2768
  card.style.display = (nameMatch && statusMatch) ? '' : 'none';
1994
2769
  });
1995
2770
  }
1996
2771
  if(historyNameFilter) historyNameFilter.addEventListener('input', filterTestHistoryCards);
1997
2772
  if(historyStatusFilter) historyStatusFilter.addEventListener('change', filterTestHistoryCards);
1998
-
1999
- // Event listener for clearing Test History filters
2000
- if (clearHistoryFiltersBtn) {
2001
- clearHistoryFiltersBtn.addEventListener('click', () => {
2002
- if (historyNameFilter) historyNameFilter.value = '';
2003
- if (historyStatusFilter) historyStatusFilter.value = '';
2004
- filterTestHistoryCards(); // Re-apply filters (which will show all)
2005
- });
2006
- }
2007
-
2008
- // --- Expand/Collapse and Toggle Details Logic (remains the same) ---
2009
- function toggleElementDetails(headerElement, contentSelector) {
2773
+ if(clearHistoryFiltersBtn) clearHistoryFiltersBtn.addEventListener('click', () => {
2774
+ if(historyNameFilter) historyNameFilter.value = ''; if(historyStatusFilter) historyStatusFilter.value = '';
2775
+ filterTestHistoryCards();
2776
+ });
2777
+ // --- Expand/Collapse and Toggle Details Logic ---
2778
+ function toggleElementDetails(headerElement, contentSelector) {
2010
2779
  let contentElement;
2011
2780
  if (headerElement.classList.contains('test-case-header')) {
2012
2781
  contentElement = headerElement.parentElement.querySelector('.test-case-content');
@@ -2016,41 +2785,179 @@ function generateHTML(reportData, trendData = null) {
2016
2785
  contentElement = null;
2017
2786
  }
2018
2787
  }
2019
-
2020
2788
  if (contentElement) {
2021
2789
  const isExpanded = contentElement.style.display === 'block';
2022
2790
  contentElement.style.display = isExpanded ? 'none' : 'block';
2023
2791
  headerElement.setAttribute('aria-expanded', String(!isExpanded));
2024
2792
  }
2025
2793
  }
2026
-
2027
- document.querySelectorAll('#test-runs .test-case-header').forEach(header => {
2028
- header.addEventListener('click', () => toggleElementDetails(header));
2029
- });
2030
- document.querySelectorAll('#test-runs .step-header').forEach(header => {
2031
- header.addEventListener('click', () => toggleElementDetails(header, '.step-details'));
2032
- });
2033
-
2034
2794
  const expandAllBtn = document.getElementById('expand-all-tests');
2035
2795
  const collapseAllBtn = document.getElementById('collapse-all-tests');
2036
-
2037
2796
  function setAllTestRunDetailsVisibility(displayMode, ariaState) {
2038
2797
  document.querySelectorAll('#test-runs .test-case-content').forEach(el => el.style.display = displayMode);
2039
2798
  document.querySelectorAll('#test-runs .step-details').forEach(el => el.style.display = displayMode);
2040
2799
  document.querySelectorAll('#test-runs .test-case-header[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', ariaState));
2041
2800
  document.querySelectorAll('#test-runs .step-header[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', ariaState));
2042
2801
  }
2043
-
2044
2802
  if (expandAllBtn) expandAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('block', 'true'));
2045
2803
  if (collapseAllBtn) collapseAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('none', 'false'));
2804
+ document.addEventListener('click', (e) => {
2805
+ const inHighcharts = e.target && e.target.closest && e.target.closest('.highcharts-container');
2806
+ if (inHighcharts) {
2807
+ return;
2808
+ }
2809
+ const header = e.target.closest('#test-runs .test-case-header');
2810
+ if (header) {
2811
+ let contentElement = header.parentElement.querySelector('.test-case-content');
2812
+ if (contentElement) {
2813
+ const isExpanded = contentElement.style.display === 'block';
2814
+ contentElement.style.display = isExpanded ? 'none' : 'block';
2815
+ header.setAttribute('aria-expanded', String(!isExpanded));
2816
+ }
2817
+ return;
2818
+ }
2819
+ const stepHeader = e.target.closest('#test-runs .step-header');
2820
+ if (stepHeader) {
2821
+ let details = stepHeader.nextElementSibling;
2822
+ if (details && details.matches('.step-details')) {
2823
+ const isExpanded = details.style.display === 'block';
2824
+ details.style.display = isExpanded ? 'none' : 'block';
2825
+ stepHeader.setAttribute('aria-expanded', String(!isExpanded));
2826
+ }
2827
+ return;
2828
+ }
2829
+ const img = e.target.closest('img.lazy-load-image');
2830
+ if (img && img.dataset && img.dataset.src) {
2831
+ if (e.preventDefault) e.preventDefault();
2832
+ img.src = img.dataset.src;
2833
+ img.removeAttribute('data-src');
2834
+ const parentLink = img.closest('a.lazy-load-attachment');
2835
+ if (parentLink && parentLink.dataset && parentLink.dataset.href) {
2836
+ parentLink.href = parentLink.dataset.href;
2837
+ parentLink.removeAttribute('data-href');
2838
+ }
2839
+ return;
2840
+ }
2841
+ const video = e.target.closest('video.lazy-load-video');
2842
+ if (video) {
2843
+ if (e.preventDefault) e.preventDefault();
2844
+ const s = video.querySelector('source');
2845
+ if (s && s.dataset && s.dataset.src && !s.src) {
2846
+ s.src = s.dataset.src;
2847
+ s.removeAttribute('data-src');
2848
+ video.load();
2849
+ } else if (video.dataset && video.dataset.src && !video.src) {
2850
+ video.src = video.dataset.src;
2851
+ video.removeAttribute('data-src');
2852
+ video.load();
2853
+ }
2854
+ return;
2855
+ }
2856
+ const a = e.target.closest('a.lazy-load-attachment');
2857
+ if (a && a.dataset && a.dataset.href) {
2858
+ e.preventDefault();
2859
+ a.href = a.dataset.href;
2860
+ a.removeAttribute('data-href');
2861
+ a.click();
2862
+ return;
2863
+ }
2864
+ });
2865
+ document.addEventListener('play', (e) => {
2866
+ const video = e.target && e.target.closest ? e.target.closest('video.lazy-load-video') : null;
2867
+ if (video) {
2868
+ const s = video.querySelector('source');
2869
+ if (s && s.dataset && s.dataset.src && !s.src) {
2870
+ s.src = s.dataset.src;
2871
+ s.removeAttribute('data-src');
2872
+ video.load();
2873
+ } else if (video.dataset && video.dataset.src && !video.src) {
2874
+ video.src = video.dataset.src;
2875
+ video.removeAttribute('data-src');
2876
+ video.load();
2877
+ }
2878
+ }
2879
+ }, true);
2880
+ const lazyLoadElements = document.querySelectorAll('.lazy-load-chart, .lazy-load-iframe');
2881
+ if ('IntersectionObserver' in window) {
2882
+ let lazyObserver = new IntersectionObserver((entries, observer) => {
2883
+ entries.forEach(entry => {
2884
+ if (entry.isIntersecting) {
2885
+ const element = entry.target;
2886
+ if (element.classList.contains('lazy-load-iframe')) {
2887
+ if (element.dataset.src) {
2888
+ element.src = element.dataset.src;
2889
+ element.removeAttribute('data-src');
2890
+ }
2891
+ } else if (element.classList.contains('lazy-load-chart')) {
2892
+ const renderFunctionName = element.dataset.renderFunctionName;
2893
+ if (renderFunctionName && typeof window[renderFunctionName] === 'function') {
2894
+ window[renderFunctionName]();
2895
+ }
2896
+ }
2897
+ observer.unobserve(element);
2898
+ }
2899
+ });
2900
+ }, { rootMargin: "0px 0px 200px 0px" });
2901
+ lazyLoadElements.forEach(el => lazyObserver.observe(el));
2902
+ } else {
2903
+ lazyLoadElements.forEach(element => {
2904
+ if (element.classList.contains('lazy-load-iframe') && element.dataset.src) element.src = element.dataset.src;
2905
+ else if (element.classList.contains('lazy-load-chart')) { const renderFn = element.dataset.renderFunctionName; if (renderFn && window[renderFn]) window[renderFn](); }
2906
+ });
2907
+ }
2046
2908
  }
2047
2909
  document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
2910
+
2911
+ function copyErrorToClipboard(button) {
2912
+ const errorContainer = button.closest('.test-error-summary');
2913
+ if (!errorContainer) {
2914
+ console.error("Could not find '.test-error-summary' container.");
2915
+ return;
2916
+ }
2917
+ let errorText;
2918
+ const stackTraceElement = errorContainer.querySelector('.stack-trace');
2919
+ if (stackTraceElement) {
2920
+ errorText = stackTraceElement.textContent;
2921
+ } else {
2922
+ const clonedContainer = errorContainer.cloneNode(true);
2923
+ const buttonInClone = clonedContainer.querySelector('button');
2924
+ if (buttonInClone) buttonInClone.remove();
2925
+ errorText = clonedContainer.textContent;
2926
+ }
2927
+
2928
+ if (!errorText) {
2929
+ button.textContent = 'Nothing to copy';
2930
+ setTimeout(() => { button.textContent = 'Copy Error'; }, 2000);
2931
+ return;
2932
+ }
2933
+ navigator.clipboard.writeText(errorText.trim()).then(() => {
2934
+ const originalText = button.textContent;
2935
+ button.textContent = 'Copied!';
2936
+ setTimeout(() => { button.textContent = originalText; }, 2000);
2937
+ }).catch(err => {
2938
+ console.error('Failed to copy: ', err);
2939
+ button.textContent = 'Failed';
2940
+ });
2941
+ }
2048
2942
  </script>
2943
+
2944
+ <!-- AI Fix Modal -->
2945
+ <div id="ai-fix-modal" class="ai-modal-overlay" onclick="closeAiModal()">
2946
+ <div class="ai-modal-content" onclick="event.stopPropagation()">
2947
+ <div class="ai-modal-header">
2948
+ <h3 id="ai-fix-modal-title">AI Analysis</h3>
2949
+ <span class="ai-modal-close" onclick="closeAiModal()">×</span>
2950
+ </div>
2951
+ <div class="ai-modal-body" id="ai-fix-modal-content">
2952
+ <!-- Content will be injected by JavaScript -->
2953
+ </div>
2954
+ </div>
2955
+ </div>
2956
+
2049
2957
  </body>
2050
2958
  </html>
2051
2959
  `;
2052
2960
  }
2053
-
2054
2961
  async function runScript(scriptPath) {
2055
2962
  return new Promise((resolve, reject) => {
2056
2963
  console.log(chalk.blue(`Executing script: ${scriptPath}...`));
@@ -2075,7 +2982,11 @@ async function runScript(scriptPath) {
2075
2982
  });
2076
2983
  });
2077
2984
  }
2078
-
2985
+ /**
2986
+ * The main function that orchestrates the generation of the static HTML report.
2987
+ * It reads the latest test run data, loads historical data for trend analysis,
2988
+ * prepares the data, and then generates and writes the final HTML report file.
2989
+ */
2079
2990
  async function main() {
2080
2991
  const __filename = fileURLToPath(import.meta.url);
2081
2992
  const __dirname = path.dirname(__filename);
@@ -2110,11 +3021,10 @@ async function main() {
2110
3021
  ),
2111
3022
  error
2112
3023
  );
2113
- // You might decide to proceed or exit depending on the importance of historical data
2114
3024
  }
2115
3025
 
2116
3026
  // Step 2: Load current run's data (for non-trend sections of the report)
2117
- let currentRunReportData; // Data for the run being reported
3027
+ let currentRunReportData;
2118
3028
  try {
2119
3029
  const jsonData = await fs.readFile(reportJsonPath, "utf-8");
2120
3030
  currentRunReportData = JSON.parse(jsonData);
@@ -2141,13 +3051,13 @@ async function main() {
2141
3051
  `Critical Error: Could not read or parse main report JSON at ${reportJsonPath}: ${error.message}`
2142
3052
  )
2143
3053
  );
2144
- process.exit(1); // Exit if the main report for the current run is missing/invalid
3054
+ process.exit(1);
2145
3055
  }
2146
3056
 
2147
3057
  // Step 3: Load historical data for trends
2148
- let historicalRuns = []; // Array of past PlaywrightPulseReport objects
3058
+ let historicalRuns = [];
2149
3059
  try {
2150
- await fs.access(historyDir); // Check if history directory exists
3060
+ await fs.access(historyDir);
2151
3061
  const allHistoryFiles = await fs.readdir(historyDir);
2152
3062
 
2153
3063
  const jsonHistoryFiles = allHistoryFiles
@@ -2165,7 +3075,7 @@ async function main() {
2165
3075
  };
2166
3076
  })
2167
3077
  .filter((file) => !isNaN(file.timestamp))
2168
- .sort((a, b) => b.timestamp - a.timestamp); // Sort newest first to easily pick the latest N
3078
+ .sort((a, b) => b.timestamp - a.timestamp);
2169
3079
 
2170
3080
  const filesToLoadForTrend = jsonHistoryFiles.slice(
2171
3081
  0,
@@ -2175,7 +3085,7 @@ async function main() {
2175
3085
  for (const fileMeta of filesToLoadForTrend) {
2176
3086
  try {
2177
3087
  const fileContent = await fs.readFile(fileMeta.path, "utf-8");
2178
- const runJsonData = JSON.parse(fileContent); // Each file IS a PlaywrightPulseReport
3088
+ const runJsonData = JSON.parse(fileContent);
2179
3089
  historicalRuns.push(runJsonData);
2180
3090
  } catch (fileReadError) {
2181
3091
  console.warn(
@@ -2185,8 +3095,7 @@ async function main() {
2185
3095
  );
2186
3096
  }
2187
3097
  }
2188
- // Reverse to have oldest first for chart data series (if charts expect chronological)
2189
- historicalRuns.reverse();
3098
+ historicalRuns.reverse(); // Oldest first for charts
2190
3099
  console.log(
2191
3100
  chalk.green(
2192
3101
  `Loaded ${historicalRuns.length} historical run(s) for trend analysis.`
@@ -2208,20 +3117,18 @@ async function main() {
2208
3117
  }
2209
3118
  }
2210
3119
 
2211
- // Step 4: Prepare trendData object in the format expected by chart functions
3120
+ // Step 4: Prepare trendData object
2212
3121
  const trendData = {
2213
- overall: [], // For overall run summaries (passed, failed, skipped, duration over time)
2214
- testRuns: {}, // For individual test history (key: "test run <run_timestamp_ms>", value: array of test result summaries)
3122
+ overall: [],
3123
+ testRuns: {},
2215
3124
  };
2216
3125
 
2217
3126
  if (historicalRuns.length > 0) {
2218
3127
  historicalRuns.forEach((histRunReport) => {
2219
- // histRunReport is a full PlaywrightPulseReport object from a past run
2220
3128
  if (histRunReport.run) {
2221
- // Ensure timestamp is a Date object for correct sorting/comparison later if needed by charts
2222
3129
  const runTimestamp = new Date(histRunReport.run.timestamp);
2223
3130
  trendData.overall.push({
2224
- runId: runTimestamp.getTime(), // Use timestamp as a unique ID for this context
3131
+ runId: runTimestamp.getTime(),
2225
3132
  timestamp: runTimestamp,
2226
3133
  duration: histRunReport.run.duration,
2227
3134
  totalTests: histRunReport.run.totalTests,
@@ -2230,21 +3137,19 @@ async function main() {
2230
3137
  skipped: histRunReport.run.skipped || 0,
2231
3138
  });
2232
3139
 
2233
- // For generateTestHistoryContent
2234
3140
  if (histRunReport.results && Array.isArray(histRunReport.results)) {
2235
- const runKeyForTestHistory = `test run ${runTimestamp.getTime()}`; // Use timestamp to key test runs
3141
+ const runKeyForTestHistory = `test run ${runTimestamp.getTime()}`;
2236
3142
  trendData.testRuns[runKeyForTestHistory] = histRunReport.results.map(
2237
3143
  (test) => ({
2238
- testName: test.name, // Full test name path
3144
+ testName: test.name,
2239
3145
  duration: test.duration,
2240
3146
  status: test.status,
2241
- timestamp: new Date(test.startTime), // Assuming test.startTime exists and is what you need
3147
+ timestamp: new Date(test.startTime),
2242
3148
  })
2243
3149
  );
2244
3150
  }
2245
3151
  }
2246
3152
  });
2247
- // Ensure trendData.overall is sorted by timestamp if not already
2248
3153
  trendData.overall.sort(
2249
3154
  (a, b) => a.timestamp.getTime() - b.timestamp.getTime()
2250
3155
  );
@@ -2252,8 +3157,6 @@ async function main() {
2252
3157
 
2253
3158
  // Step 5: Generate and write HTML
2254
3159
  try {
2255
- // currentRunReportData is for the main content (test list, summary cards of *this* run)
2256
- // trendData is for the historical charts and test history section
2257
3160
  const htmlContent = generateHTML(currentRunReportData, trendData);
2258
3161
  await fs.writeFile(reportHtmlPath, htmlContent, "utf-8");
2259
3162
  console.log(
@@ -2264,16 +3167,14 @@ async function main() {
2264
3167
  console.log(chalk.gray(`(You can open this file in your browser)`));
2265
3168
  } catch (error) {
2266
3169
  console.error(chalk.red(`Error generating HTML report: ${error.message}`));
2267
- console.error(chalk.red(error.stack)); // Log full stack for HTML generation errors
3170
+ console.error(chalk.red(error.stack));
2268
3171
  process.exit(1);
2269
3172
  }
2270
3173
  }
2271
-
2272
- // Make sure main() is called at the end of your script
2273
3174
  main().catch((err) => {
2274
3175
  console.error(
2275
3176
  chalk.red.bold(`Unhandled error during script execution: ${err.message}`)
2276
3177
  );
2277
3178
  console.error(err.stack);
2278
3179
  process.exit(1);
2279
- });
3180
+ });