@arghajit/playwright-pulse-report 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as fs from "fs/promises";
4
- import { readFileSync, existsSync as fsExistsSync } from "fs"; // 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
6
+ import { fork } from "child_process";
7
+ import { fileURLToPath } from "url";
8
8
 
9
9
  // Use dynamic import for chalk as it's ESM only
10
10
  let chalk;
@@ -21,16 +21,17 @@ try {
21
21
  gray: (text) => text,
22
22
  };
23
23
  }
24
+
24
25
  // Default configuration
25
26
  const DEFAULT_OUTPUT_DIR = "pulse-report";
26
27
  const DEFAULT_JSON_FILE = "playwright-pulse-report.json";
27
28
  const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
29
+
28
30
  // Helper functions
29
31
  export function ansiToHtml(text) {
30
32
  if (!text) {
31
33
  return "";
32
34
  }
33
-
34
35
  const codes = {
35
36
  0: "color:inherit;font-weight:normal;font-style:normal;text-decoration:none;opacity:1;background-color:inherit;",
36
37
  1: "font-weight:bold",
@@ -82,18 +83,14 @@ export function ansiToHtml(text) {
82
83
  }
83
84
  }
84
85
  };
85
-
86
86
  const resetAndApplyNewCodes = (newCodesStr) => {
87
87
  const newCodes = newCodesStr.split(";");
88
-
89
88
  if (newCodes.includes("0")) {
90
89
  currentStylesArray = [];
91
90
  if (codes["0"]) currentStylesArray.push(codes["0"]);
92
91
  }
93
-
94
92
  for (const code of newCodes) {
95
93
  if (code === "0") continue;
96
-
97
94
  if (codes[code]) {
98
95
  if (code === "39") {
99
96
  currentStylesArray = currentStylesArray.filter(
@@ -123,12 +120,9 @@ export function ansiToHtml(text) {
123
120
  }
124
121
  applyStyles();
125
122
  };
126
-
127
123
  const segments = text.split(/(\x1b\[[0-9;]*m)/g);
128
-
129
124
  for (const segment of segments) {
130
125
  if (!segment) continue;
131
-
132
126
  if (segment.startsWith("\x1b[") && segment.endsWith("m")) {
133
127
  const command = segment.slice(2, -1);
134
128
  resetAndApplyNewCodes(command);
@@ -142,28 +136,23 @@ export function ansiToHtml(text) {
142
136
  html += escapedContent;
143
137
  }
144
138
  }
145
-
146
139
  if (openSpan) {
147
140
  html += "</span>";
148
141
  }
149
-
150
142
  return html;
151
143
  }
144
+
152
145
  function sanitizeHTML(str) {
153
146
  if (str === null || str === undefined) return "";
154
- return String(str).replace(/[&<>"']/g, (match) => {
155
- const replacements = {
156
- "&": "&",
157
- "<": "<",
158
- ">": ">",
159
- '"': '"',
160
- "'": "'", // or '
161
- };
162
- return replacements[match] || match;
163
- });
147
+ return String(str).replace(
148
+ /[&<>"']/g,
149
+ (match) =>
150
+ ({ "&": "&", "<": "<", ">": ">", '"': '"', "'": "'" }[match] || match)
151
+ );
164
152
  }
153
+
165
154
  function capitalize(str) {
166
- if (!str) return ""; // Handle empty string
155
+ if (!str) return "";
167
156
  return str[0].toUpperCase() + str.slice(1).toLowerCase();
168
157
  }
169
158
  function formatPlaywrightError(error) {
@@ -1304,11 +1293,12 @@ function generateHTML(reportData, trendData = null) {
1304
1293
  runSummary.totalTests > 0
1305
1294
  ? formatDuration(runSummary.duration / runSummary.totalTests)
1306
1295
  : "0.0s";
1296
+
1307
1297
  function generateTestCasesHTML() {
1308
1298
  if (!results || results.length === 0)
1309
1299
  return '<div class="no-tests">No test results found in this run.</div>';
1310
1300
  return results
1311
- .map((test, index) => {
1301
+ .map((test) => {
1312
1302
  const browser = test.browser || "unknown";
1313
1303
  const testFileParts = test.name.split(" > ");
1314
1304
  const testTitle =
@@ -1324,319 +1314,242 @@ function generateHTML(reportData, trendData = null) {
1324
1314
  ? `step-hook step-hook-${step.hookType}`
1325
1315
  : "";
1326
1316
  const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
1327
- return `
1328
- <div class="step-item" style="--depth: ${depth};">
1329
- <div class="step-header ${stepClass}" role="button" aria-expanded="false">
1330
- <span class="step-icon">${getStatusIcon(step.status)}</span>
1331
- <span class="step-title">${sanitizeHTML(
1317
+ return `<div class="step-item" style="--depth: ${depth};"><div class="step-header ${stepClass}" role="button" aria-expanded="false"><span class="step-icon">${getStatusIcon(
1318
+ step.status
1319
+ )}</span><span class="step-title">${sanitizeHTML(
1332
1320
  step.title
1333
- )}${hookIndicator}</span>
1334
- <span class="step-duration">${formatDuration(
1321
+ )}${hookIndicator}</span><span class="step-duration">${formatDuration(
1335
1322
  step.duration
1336
- )}</span>
1337
- </div>
1338
- <div class="step-details" style="display: none;">
1339
- ${
1323
+ )}</span></div><div class="step-details" style="display: none;">${
1340
1324
  step.codeLocation
1341
1325
  ? `<div class="step-info"><strong>Location:</strong> ${sanitizeHTML(
1342
1326
  step.codeLocation
1343
1327
  )}</div>`
1344
1328
  : ""
1345
- }
1346
- ${
1329
+ }${
1347
1330
  step.errorMessage
1348
- ? `<div class="step-error">
1349
- ${
1350
- step.stackTrace
1351
- ? `<div class="stack-trace">${formatPlaywrightError(
1352
- step.stackTrace
1353
- )}</div>`
1354
- : ""
1355
- }
1356
- <button
1357
- class="copy-error-btn"
1358
- onclick="copyErrorToClipboard(this)"
1359
- style="
1360
- margin-top: 8px;
1361
- padding: 4px 8px;
1362
- background: #f0f0f0;
1363
- border: 2px solid #ccc;
1364
- border-radius: 4px;
1365
- cursor: pointer;
1366
- font-size: 12px;
1367
- border-color: #8B0000;
1368
- color: #8B0000;
1369
- "
1370
- onmouseover="this.style.background='#e0e0e0'"
1371
- onmouseout="this.style.background='#f0f0f0'"
1372
- >
1373
- Copy Error Prompt
1374
- </button>
1375
- </div>`
1331
+ ? `<div class="step-error">${
1332
+ step.stackTrace
1333
+ ? `<div class="stack-trace">${formatPlaywrightError(
1334
+ step.stackTrace
1335
+ )}</div>`
1336
+ : ""
1337
+ }<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
1376
1338
  : ""
1377
- }
1378
- ${
1339
+ }${
1379
1340
  hasNestedSteps
1380
1341
  ? `<div class="nested-steps">${generateStepsHTML(
1381
1342
  step.steps,
1382
1343
  depth + 1
1383
1344
  )}</div>`
1384
1345
  : ""
1385
- }
1386
- </div>
1387
- </div>`;
1346
+ }</div></div>`;
1388
1347
  })
1389
1348
  .join("");
1390
1349
  };
1391
-
1392
- // Local escapeHTML for screenshot rendering part, ensuring it uses proper entities
1393
- const escapeHTMLForScreenshots = (str) => {
1394
- if (str === null || str === undefined) return "";
1395
- return String(str).replace(
1396
- /[&<>"']/g,
1397
- (match) =>
1398
- ({ "&": "&", "<": "<", ">": ">", '"': '"', "'": "'" }[match] ||
1399
- match)
1400
- );
1401
- };
1402
- return `
1403
- <div class="test-case" data-status="${
1404
- test.status
1405
- }" data-browser="${sanitizeHTML(browser)}" data-tags="${(test.tags || [])
1350
+ return `<div class="test-case" data-status="${
1351
+ test.status
1352
+ }" data-browser="${sanitizeHTML(browser)}" data-tags="${(
1353
+ test.tags || []
1354
+ )
1406
1355
  .join(",")
1407
1356
  .toLowerCase()}">
1408
- <div class="test-case-header" role="button" aria-expanded="false">
1409
- <div class="test-case-summary">
1410
- <span class="status-badge ${getStatusClass(test.status)}">${String(
1357
+ <div class="test-case-header" role="button" aria-expanded="false"><div class="test-case-summary"><span class="status-badge ${getStatusClass(
1358
+ test.status
1359
+ )}">${String(
1411
1360
  test.status
1412
- ).toUpperCase()}</span>
1413
- <span class="test-case-title" title="${sanitizeHTML(
1414
- test.name
1415
- )}">${sanitizeHTML(testTitle)}</span>
1416
- <span class="test-case-browser">(${sanitizeHTML(browser)})</span>
1417
- </div>
1418
- <div class="test-case-meta">
1419
- ${
1420
- test.tags && test.tags.length > 0
1421
- ? test.tags
1422
- .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1423
- .join(" ")
1424
- : ""
1425
- }
1426
- <span class="test-duration">${formatDuration(test.duration)}</span>
1427
- </div>
1428
- </div>
1429
- <div class="test-case-content" style="display: none;">
1430
- <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
1431
- ${
1432
- test.errorMessage
1433
- ? `<div class="test-error-summary">${formatPlaywrightError(
1434
- test.errorMessage
1435
- )}
1436
- <button
1437
- class="copy-error-btn"
1438
- onclick="copyErrorToClipboard(this)"
1439
- style="
1440
- margin-top: 8px;
1441
- padding: 4px 8px;
1442
- background: #f0f0f0;
1443
- border: 2px solid #ccc;
1444
- border-radius: 4px;
1445
- cursor: pointer;
1446
- font-size: 12px;
1447
- border-color: #8B0000;
1448
- color: #8B0000;
1449
- "
1450
- onmouseover="this.style.background='#e0e0e0'"
1451
- onmouseout="this.style.background='#f0f0f0'"
1452
- >
1453
- Copy Error Prompt
1454
- </button>
1455
- </div>`
1456
- : ""
1457
- }
1458
- <h4>Steps</h4>
1459
- <div class="steps-list">${generateStepsHTML(test.steps)}</div>
1460
- ${
1461
- test.stdout && test.stdout.length > 0
1462
- ? `<div class="console-output-section"><h4>Console Output (stdout)</h4><pre class="console-log stdout-log" style="background-color: #2d2d2d; color: wheat; padding: 1.25em; border-radius: 0.85em; line-height: 1.2;">${test.stdout
1463
- .map((line) => sanitizeHTML(line))
1464
- .join("\n")}</pre></div>`
1465
- : ""
1466
- }
1467
- ${
1468
- test.stderr && test.stderr.length > 0
1469
- ? `<div class="console-output-section"><h4>Console Output (stderr)</h4><pre class="console-log stderr-log" style="background-color: #2d2d2d; color: indianred; padding: 1.25em; border-radius: 0.85em; line-height: 1.2;">${test.stderr
1470
- .map((line) => sanitizeHTML(line))
1471
- .join("\n")}</pre></div>`
1472
- : ""
1473
- }
1474
- ${(() => {
1475
- // Screenshots
1476
- if (!test.screenshots || test.screenshots.length === 0) return "";
1477
- const baseOutputDir = path.resolve(
1478
- process.cwd(),
1479
- DEFAULT_OUTPUT_DIR
1480
- );
1481
-
1482
- const renderScreenshot = (screenshotPathOrData, index) => {
1483
- let base64ImageData = "";
1484
- const uniqueSuffix = `${Date.now()}-${index}-${Math.random()
1485
- .toString(36)
1486
- .substring(2, 7)}`;
1487
- try {
1488
- if (
1489
- typeof screenshotPathOrData === "string" &&
1490
- !screenshotPathOrData.startsWith("data:image")
1491
- ) {
1492
- const imagePath = path.resolve(
1493
- baseOutputDir,
1494
- screenshotPathOrData
1495
- );
1496
- if (fsExistsSync(imagePath))
1497
- base64ImageData =
1498
- readFileSync(imagePath).toString("base64");
1499
- else {
1500
- console.warn(
1501
- chalk.yellow(
1502
- `[Reporter] Screenshot file not found: ${imagePath}`
1503
- )
1504
- );
1505
- return `<div class="attachment-item error" style="padding:10px; color:red;">Screenshot not found: ${escapeHTMLForScreenshots(
1506
- screenshotPathOrData
1507
- )}</div>`;
1508
- }
1509
- } else if (
1510
- typeof screenshotPathOrData === "string" &&
1511
- screenshotPathOrData.startsWith("data:image/png;base64,")
1512
- )
1513
- base64ImageData = screenshotPathOrData.substring(
1514
- "data:image/png;base64,".length
1515
- );
1516
- else if (typeof screenshotPathOrData === "string")
1517
- base64ImageData = screenshotPathOrData;
1518
- else {
1519
- console.warn(
1520
- chalk.yellow(
1521
- `[Reporter] Invalid screenshot data type for item at index ${index}.`
1522
- )
1523
- );
1524
- return `<div class="attachment-item error" style="padding:10px; color:red;">Invalid screenshot data</div>`;
1525
- }
1526
- if (!base64ImageData) {
1527
- console.warn(
1528
- chalk.yellow(
1529
- `[Reporter] Could not obtain base64 data for screenshot: ${escapeHTMLForScreenshots(
1530
- String(screenshotPathOrData)
1531
- )}`
1532
- )
1533
- );
1534
- return `<div class="attachment-item error" style="padding:10px; color:red;">Error loading screenshot: ${escapeHTMLForScreenshots(
1535
- String(screenshotPathOrData)
1536
- )}</div>`;
1537
- }
1538
- return `<div class="attachment-item"><img src="data:image/png;base64,${base64ImageData}" alt="Screenshot ${
1539
- index + 1
1540
- }" loading="lazy" onerror="this.alt='Error displaying embedded image'; this.style.display='none'; this.parentElement.innerHTML='<p style=\\'color:red;padding:10px;\\'>Error displaying screenshot ${
1541
- index + 1
1542
- }.</p>';"><div class="attachment-info"><div class="trace-actions"><a href="data:image/png;base64,${base64ImageData}" target="_blank" class="view-full">View Full Image</a><a href="data:image/png;base64,${base64ImageData}" target="_blank" download="screenshot-${uniqueSuffix}.png">Download</a></div></div></div>`;
1543
- } catch (e) {
1544
- console.error(
1545
- chalk.red(
1546
- `[Reporter] Error processing screenshot ${escapeHTMLForScreenshots(
1547
- String(screenshotPathOrData)
1548
- )}: ${e.message}`
1549
- )
1550
- );
1551
- return `<div class="attachment-item error" style="padding:10px; color:red;">Failed to load screenshot: ${escapeHTMLForScreenshots(
1552
- String(screenshotPathOrData)
1553
- )}</div>`;
1554
- }
1555
- };
1556
- return `<div class="attachments-section"><h4>Screenshots (${
1557
- test.screenshots.length
1558
- })</h4><div class="attachments-grid">${test.screenshots
1559
- .map(renderScreenshot)
1560
- .join("")}</div></div>`;
1561
- })()}
1562
- ${
1563
- test.videoPath
1564
- ? `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${(() => {
1565
- // Videos
1566
- const videos = Array.isArray(test.videoPath)
1567
- ? test.videoPath
1568
- : [test.videoPath];
1569
- const mimeTypes = {
1570
- mp4: "video/mp4",
1571
- webm: "video/webm",
1572
- ogg: "video/ogg",
1573
- mov: "video/quicktime",
1574
- avi: "video/x-msvideo",
1575
- };
1576
- return videos
1577
- .map((video, index) => {
1578
- const videoUrl =
1579
- typeof video === "object" ? video.url || "" : video;
1580
- const videoName =
1581
- typeof video === "object"
1582
- ? video.name || `Video ${index + 1}`
1583
- : `Video ${index + 1}`;
1584
- const fileExtension = String(videoUrl)
1585
- .split(".")
1586
- .pop()
1587
- .toLowerCase();
1588
- const mimeType = mimeTypes[fileExtension] || "video/mp4";
1589
- return `<div class="attachment-item"><video controls width="100%" height="auto" title="${sanitizeHTML(
1590
- videoName
1591
- )}"><source src="${sanitizeHTML(
1592
- videoUrl
1593
- )}" type="${mimeType}">Your browser does not support the video tag.</video><div class="attachment-info"><div class="trace-actions"><a href="${sanitizeHTML(
1594
- videoUrl
1595
- )}" target="_blank" download="${sanitizeHTML(
1596
- videoName
1597
- )}.${fileExtension}">Download</a></div></div></div>`;
1598
- })
1599
- .join("");
1600
- })()}</div></div>`
1601
- : ""
1602
- }
1603
- ${
1604
- test.tracePath
1605
- ? `<div class="attachments-section"><h4>Trace Files</h4><div class="attachments-grid">${(() => {
1606
- // Traces
1607
- const traces = Array.isArray(test.tracePath)
1608
- ? test.tracePath
1609
- : [test.tracePath];
1610
- return traces
1611
- .map((trace, index) => {
1612
- const traceUrl =
1613
- typeof trace === "object" ? trace.url || "" : trace;
1614
- const traceName =
1615
- typeof trace === "object"
1616
- ? trace.name || `Trace ${index + 1}`
1617
- : `Trace ${index + 1}`;
1618
- const traceFileName = String(traceUrl).split("/").pop();
1619
- return `<div class="attachment-item"><div class="trace-preview"><span class="trace-icon">📄</span><span class="trace-name">${sanitizeHTML(
1620
- traceName
1621
- )}</span></div><div class="attachment-info"><div class="trace-actions"><a href="${sanitizeHTML(
1622
- traceUrl
1623
- )}" target="_blank" download="${sanitizeHTML(
1624
- traceFileName
1625
- )}" class="download-trace">Download</a></div></div></div>`;
1626
- })
1627
- .join("");
1628
- })()}</div></div>`
1629
- : ""
1630
- }
1631
- ${
1632
- test.codeSnippet
1633
- ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
1634
- test.codeSnippet
1635
- )}</code></pre></div>`
1636
- : ""
1637
- }
1638
- </div>
1639
- </div>`;
1361
+ ).toUpperCase()}</span><span class="test-case-title" title="${sanitizeHTML(
1362
+ test.name
1363
+ )}">${sanitizeHTML(
1364
+ testTitle
1365
+ )}</span><span class="test-case-browser">(${sanitizeHTML(
1366
+ browser
1367
+ )})</span></div><div class="test-case-meta">${
1368
+ test.tags && test.tags.length > 0
1369
+ ? test.tags
1370
+ .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1371
+ .join(" ")
1372
+ : ""
1373
+ }<span class="test-duration">${formatDuration(
1374
+ test.duration
1375
+ )}</span></div></div>
1376
+ <div class="test-case-content" style="display: none;">
1377
+ <p><strong>Full Path:</strong> ${sanitizeHTML(
1378
+ test.name
1379
+ )}</p>
1380
+ <p><strong>Test run Worker ID:</strong> ${sanitizeHTML(
1381
+ test.workerId
1382
+ )} [<strong>Total No. of Workers:</strong> ${sanitizeHTML(
1383
+ test.totalWorkers
1384
+ )}]</p>
1385
+ ${
1386
+ test.errorMessage
1387
+ ? `<div class="test-error-summary">${formatPlaywrightError(
1388
+ test.errorMessage
1389
+ )}<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
1390
+ : ""
1391
+ }
1392
+ <h4>Steps</h4><div class="steps-list">${generateStepsHTML(
1393
+ test.steps
1394
+ )}</div>
1395
+ ${
1396
+ test.stdout && test.stdout.length > 0
1397
+ ? `<div class="console-output-section"><h4>Console Output (stdout)</h4><pre class="console-log stdout-log">${formatPlaywrightError(
1398
+ test.stdout.join("\\n")
1399
+ )}</pre></div>`
1400
+ : ""
1401
+ }
1402
+ ${
1403
+ test.stderr && test.stderr.length > 0
1404
+ ? `<div class="console-output-section"><h4>Console Output (stderr)</h4><pre class="console-log stderr-log">${test.stderr
1405
+ .map((line) => sanitizeHTML(line))
1406
+ .join("\\n")}</pre></div>`
1407
+ : ""
1408
+ }
1409
+
1410
+ ${(() => {
1411
+ if (
1412
+ !test.screenshots ||
1413
+ test.screenshots.length === 0
1414
+ )
1415
+ return "";
1416
+ return `<div class="attachments-section"><h4>Screenshots</h4><div class="attachments-grid">${test.screenshots
1417
+ .map((screenshotPath, index) => {
1418
+ try {
1419
+ const imagePath = path.resolve(
1420
+ DEFAULT_OUTPUT_DIR,
1421
+ screenshotPath
1422
+ );
1423
+ if (!fsExistsSync(imagePath))
1424
+ return `<div class="attachment-item error">Screenshot not found: ${sanitizeHTML(
1425
+ screenshotPath
1426
+ )}</div>`;
1427
+ const base64ImageData =
1428
+ readFileSync(imagePath).toString("base64");
1429
+ return `<div class="attachment-item"><img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" data-src="data:image/png;base64,${base64ImageData}" alt="Screenshot ${
1430
+ index + 1
1431
+ }" class="lazy-load-image"><div class="attachment-info"><div class="trace-actions"><a href="data:image/png;base64,${base64ImageData}" target="_blank" download="screenshot-${index}.png">Download</a></div></div></div>`;
1432
+ } catch (e) {
1433
+ return `<div class="attachment-item error">Failed to load screenshot: ${sanitizeHTML(
1434
+ screenshotPath
1435
+ )}</div>`;
1436
+ }
1437
+ })
1438
+ .join("")}</div></div>`;
1439
+ })()}
1440
+
1441
+ ${(() => {
1442
+ if (!test.videoPath || test.videoPath.length === 0)
1443
+ return "";
1444
+ return `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${test.videoPath
1445
+ .map((videoPath, index) => {
1446
+ try {
1447
+ const videoFilePath = path.resolve(
1448
+ DEFAULT_OUTPUT_DIR,
1449
+ videoPath
1450
+ );
1451
+ if (!fsExistsSync(videoFilePath))
1452
+ return `<div class="attachment-item error">Video not found: ${sanitizeHTML(
1453
+ videoPath
1454
+ )}</div>`;
1455
+ const videoBase64 =
1456
+ readFileSync(videoFilePath).toString(
1457
+ "base64"
1458
+ );
1459
+ const fileExtension = path
1460
+ .extname(videoPath)
1461
+ .slice(1)
1462
+ .toLowerCase();
1463
+ const mimeType =
1464
+ {
1465
+ mp4: "video/mp4",
1466
+ webm: "video/webm",
1467
+ ogg: "video/ogg",
1468
+ mov: "video/quicktime",
1469
+ avi: "video/x-msvideo",
1470
+ }[fileExtension] || "video/mp4";
1471
+ const videoDataUri = `data:${mimeType};base64,${videoBase64}`;
1472
+ return `<div class="attachment-item video-item"><video controls preload="none" class="lazy-load-video"><source data-src="${videoDataUri}" type="${mimeType}"></video><div class="attachment-info"><div class="trace-actions"><a href="${videoDataUri}" target="_blank" download="video-${index}.${fileExtension}">Download</a></div></div></div>`;
1473
+ } catch (e) {
1474
+ return `<div class="attachment-item error">Failed to load video: ${sanitizeHTML(
1475
+ videoPath
1476
+ )}</div>`;
1477
+ }
1478
+ })
1479
+ .join("")}</div></div>`;
1480
+ })()}
1481
+
1482
+ ${(() => {
1483
+ if (!test.tracePath) return "";
1484
+ try {
1485
+ const traceFilePath = path.resolve(
1486
+ DEFAULT_OUTPUT_DIR,
1487
+ test.tracePath
1488
+ );
1489
+ if (!fsExistsSync(traceFilePath))
1490
+ return `<div class="attachments-section"><h4>Trace File</h4><div class="attachment-item error">Trace file not found: ${sanitizeHTML(
1491
+ test.tracePath
1492
+ )}</div></div>`;
1493
+ const traceBase64 =
1494
+ readFileSync(traceFilePath).toString("base64");
1495
+ const traceDataUri = `data:application/zip;base64,${traceBase64}`;
1496
+ return `<div class="attachments-section"><h4>Trace File</h4><div class="attachments-grid"><div class="attachment-item generic-attachment"><div class="attachment-icon">📄</div><div class="attachment-caption"><span class="attachment-name">trace.zip</span></div><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="${traceDataUri}" class="lazy-load-attachment" download="trace.zip">Download Trace</a></div></div></div></div></div>`;
1497
+ } catch (e) {
1498
+ return `<div class="attachments-section"><h4>Trace File</h4><div class="attachment-item error">Failed to load trace file.</div></div>`;
1499
+ }
1500
+ })()}
1501
+
1502
+ ${(() => {
1503
+ if (
1504
+ !test.attachments ||
1505
+ test.attachments.length === 0
1506
+ )
1507
+ return "";
1508
+ return `<div class="attachments-section"><h4>Other Attachments</h4><div class="attachments-grid">${test.attachments
1509
+ .map((attachment) => {
1510
+ try {
1511
+ const attachmentPath = path.resolve(
1512
+ DEFAULT_OUTPUT_DIR,
1513
+ attachment.path
1514
+ );
1515
+ if (!fsExistsSync(attachmentPath))
1516
+ return `<div class="attachment-item error">Attachment not found: ${sanitizeHTML(
1517
+ attachment.name
1518
+ )}</div>`;
1519
+ const attachmentBase64 =
1520
+ readFileSync(attachmentPath).toString(
1521
+ "base64"
1522
+ );
1523
+ const attachmentDataUri = `data:${attachment.contentType};base64,${attachmentBase64}`;
1524
+ return `<div class="attachment-item generic-attachment"><div class="attachment-icon">${getAttachmentIcon(
1525
+ attachment.contentType
1526
+ )}</div><div class="attachment-caption"><span class="attachment-name" title="${sanitizeHTML(
1527
+ attachment.name
1528
+ )}">${sanitizeHTML(
1529
+ attachment.name
1530
+ )}</span><span class="attachment-type">${sanitizeHTML(
1531
+ attachment.contentType
1532
+ )}</span></div><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(
1533
+ attachment.name
1534
+ )}">Download</a></div></div></div>`;
1535
+ } catch (e) {
1536
+ return `<div class="attachment-item error">Failed to load attachment: ${sanitizeHTML(
1537
+ attachment.name
1538
+ )}</div>`;
1539
+ }
1540
+ })
1541
+ .join("")}</div></div>`;
1542
+ })()}
1543
+
1544
+ ${
1545
+ test.codeSnippet
1546
+ ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
1547
+ test.codeSnippet
1548
+ )}</code></pre></div>`
1549
+ : ""
1550
+ }
1551
+ </div>
1552
+ </div>`;
1640
1553
  })
1641
1554
  .join("");
1642
1555
  }
@@ -1668,7 +1581,6 @@ function generateHTML(reportData, trendData = null) {
1668
1581
  .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
1669
1582
  .highcharts-axis-title { fill: var(--text-color) !important; }
1670
1583
  .highcharts-tooltip > span { background-color: rgba(10,10,10,0.92) !important; border-color: rgba(10,10,10,0.92) !important; color: #f5f5f5 !important; padding: 10px !important; border-radius: 6px !important; }
1671
-
1672
1584
  body { font-family: var(--font-family); margin: 0; background-color: var(--background-color); color: var(--text-color); line-height: 1.65; font-size: 16px; }
1673
1585
  .container { padding: 30px; border-radius: var(--border-radius); box-shadow: var(--box-shadow); background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec); }
1674
1586
  .header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; padding-bottom: 25px; border-bottom: 1px solid var(--border-color); margin-bottom: 25px; }
@@ -1763,7 +1675,8 @@ function generateHTML(reportData, trendData = null) {
1763
1675
  .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
1764
1676
  .attachment-item { border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: #fff; box-shadow: var(--box-shadow-light); overflow: hidden; display: flex; flex-direction: column; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; }
1765
1677
  .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
1766
- .attachment-item img { width: 100%; height: 180px; object-fit: cover; display: block; border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease; }
1678
+ .attachment-item img, .attachment-item video { width: 100%; height: 180px; object-fit: cover; display: block; background-color: #eee; border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease; }
1679
+ .attachment-info { padding: 12px; margin-top: auto; background-color: #fafafa;}
1767
1680
  .attachment-item a:hover img { opacity: 0.85; }
1768
1681
  .attachment-caption { padding: 12px 15px; font-size: 0.9em; text-align: center; color: var(--text-color-secondary); word-break: break-word; background-color: var(--light-gray-color); }
1769
1682
  .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
@@ -1807,6 +1720,12 @@ function generateHTML(reportData, trendData = null) {
1807
1720
  @media (max-width: 992px) { .dashboard-bottom-row { grid-template-columns: 1fr; } .pie-chart-wrapper div[id^="pieChart-"] { max-width: 350px; margin: 0 auto; } .filters input { min-width: 180px; } .filters select { min-width: 150px; } }
1808
1721
  @media (max-width: 768px) { body { font-size: 15px; } .container { margin: 10px; padding: 20px; } .header { flex-direction: column; align-items: flex-start; gap: 15px; } .header h1 { font-size: 1.6em; } .run-info { text-align: left; font-size:0.9em; } .tabs { margin-bottom: 25px;} .tab-button { padding: 12px 20px; font-size: 1.05em;} .dashboard-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px;} .summary-card .value {font-size: 2em;} .summary-card h3 {font-size: 0.95em;} .filters { flex-direction: column; padding: 18px; gap: 12px;} .filters input, .filters select, .filters button {width: 100%; box-sizing: border-box;} .test-case-header { flex-direction: column; align-items: flex-start; gap: 10px; padding: 14px; } .test-case-summary {gap: 10px;} .test-case-title {font-size: 1.05em;} .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;} .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;} .test-history-grid {grid-template-columns: 1fr;} .pie-chart-wrapper {min-height: auto;} }
1809
1722
  @media (max-width: 480px) { body {font-size: 14px;} .container {padding: 15px;} .header h1 {font-size: 1.4em;} #report-logo { height: 35px; width: 35px; } .tab-button {padding: 10px 15px; font-size: 1em;} .summary-card .value {font-size: 1.8em;} .attachments-grid {grid-template-columns: 1fr;} .step-item {padding-left: calc(var(--depth, 0) * 18px);} .test-case-content, .step-details {padding: 15px;} .trend-charts-row {gap: 20px;} .trend-chart {padding: 20px;} }
1723
+ .trace-actions a { text-decoration: none; color: var(--primary-color); font-weight: 500; font-size: 0.9em; }
1724
+ .generic-attachment { text-align: center; padding: 1rem; justify-content: center; align-items: center; }
1725
+ .attachment-icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
1726
+ .attachment-caption { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; }
1727
+ .attachment-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
1728
+ .attachment-type { font-size: 0.8rem; color: var(--text-color-secondary); }
1810
1729
  </style>
1811
1730
  </head>
1812
1731
  <body>
@@ -2035,84 +1954,115 @@ function generateHTML(reportData, trendData = null) {
2035
1954
  if (expandAllBtn) expandAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('block', 'true'));
2036
1955
  if (collapseAllBtn) collapseAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('none', 'false'));
2037
1956
  // --- Intersection Observer for Lazy Loading ---
2038
- const lazyLoadElements = document.querySelectorAll('.lazy-load-chart, .lazy-load-iframe');
1957
+ const lazyLoadElements = document.querySelectorAll('.lazy-load-chart, .lazy-load-iframe, .lazy-load-image, .lazy-load-video, .lazy-load-attachment');
2039
1958
  if ('IntersectionObserver' in window) {
2040
1959
  let lazyObserver = new IntersectionObserver((entries, observer) => {
2041
1960
  entries.forEach(entry => {
2042
1961
  if (entry.isIntersecting) {
2043
1962
  const element = entry.target;
2044
- if (element.classList.contains('lazy-load-iframe')) {
1963
+ if (element.classList.contains('lazy-load-image')) {
1964
+ if (element.dataset.src) {
1965
+ element.src = element.dataset.src;
1966
+ element.removeAttribute('data-src');
1967
+ }
1968
+ } else if (element.classList.contains('lazy-load-video')) {
1969
+ const source = element.querySelector('source');
1970
+ if (source && source.dataset.src) {
1971
+ source.src = source.dataset.src;
1972
+ source.removeAttribute('data-src');
1973
+ element.load();
1974
+ }
1975
+ } else if (element.classList.contains('lazy-load-attachment')) {
1976
+ if (element.dataset.href) {
1977
+ element.href = element.dataset.href;
1978
+ element.removeAttribute('data-href');
1979
+ }
1980
+ } else if (element.classList.contains('lazy-load-iframe')) {
2045
1981
  if (element.dataset.src) {
2046
1982
  element.src = element.dataset.src;
2047
- element.removeAttribute('data-src'); // Optional: remove data-src after loading
2048
- console.log('Lazy loaded iframe:', element.title || 'Untitled Iframe');
1983
+ element.removeAttribute('data-src');
2049
1984
  }
2050
1985
  } else if (element.classList.contains('lazy-load-chart')) {
2051
1986
  const renderFunctionName = element.dataset.renderFunctionName;
2052
1987
  if (renderFunctionName && typeof window[renderFunctionName] === 'function') {
2053
- try {
2054
- console.log('Lazy loading chart with function:', renderFunctionName);
2055
- window[renderFunctionName](); // Call the render function
2056
- } catch (e) {
2057
- console.error(\`Error lazy-loading chart \${element.id} using \${renderFunctionName}:\`, e);
2058
- element.innerHTML = '<div class="no-data-chart">Error lazy-loading chart.</div>';
2059
- }
2060
- } else {
2061
- console.warn(\`Render function \${renderFunctionName} not found or not a function for chart:\`, element.id);
1988
+ window[renderFunctionName]();
2062
1989
  }
2063
1990
  }
2064
- observer.unobserve(element); // Important: stop observing once loaded
1991
+ observer.unobserve(element);
2065
1992
  }
2066
1993
  });
2067
- }, {
2068
- rootMargin: "0px 0px 200px 0px" // Start loading when element is 200px from viewport bottom
2069
- });
2070
-
2071
- lazyLoadElements.forEach(el => {
2072
- lazyObserver.observe(el);
2073
- });
1994
+ }, { rootMargin: "0px 0px 200px 0px" });
1995
+ lazyLoadElements.forEach(el => lazyObserver.observe(el));
2074
1996
  } else { // Fallback for browsers without IntersectionObserver
2075
- console.warn("IntersectionObserver not supported. Loading all items immediately.");
2076
1997
  lazyLoadElements.forEach(element => {
2077
- if (element.classList.contains('lazy-load-iframe')) {
2078
- if (element.dataset.src) {
2079
- element.src = element.dataset.src;
2080
- element.removeAttribute('data-src');
2081
- }
2082
- } else if (element.classList.contains('lazy-load-chart')) {
2083
- const renderFunctionName = element.dataset.renderFunctionName;
2084
- if (renderFunctionName && typeof window[renderFunctionName] === 'function') {
2085
- try {
2086
- window[renderFunctionName]();
2087
- } catch (e) {
2088
- console.error(\`Error loading chart (fallback) \${element.id} using \${renderFunctionName}:\`, e);
2089
- element.innerHTML = '<div class="no-data-chart">Error loading chart (fallback).</div>';
2090
- }
2091
- }
2092
- }
1998
+ if (element.classList.contains('lazy-load-image') && element.dataset.src) element.src = element.dataset.src;
1999
+ else if (element.classList.contains('lazy-load-video')) { const source = element.querySelector('source'); if (source && source.dataset.src) { source.src = source.dataset.src; element.load(); } }
2000
+ else if (element.classList.contains('lazy-load-attachment') && element.dataset.href) element.href = element.dataset.href;
2001
+ else if (element.classList.contains('lazy-load-iframe') && element.dataset.src) element.src = element.dataset.src;
2002
+ else if (element.classList.contains('lazy-load-chart')) { const renderFn = element.dataset.renderFunctionName; if(renderFn && window[renderFn]) window[renderFn](); }
2093
2003
  });
2094
2004
  }
2095
2005
  }
2096
2006
  document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
2097
2007
 
2098
2008
  function copyErrorToClipboard(button) {
2099
- const errorContainer = button.closest('.step-error');
2100
- const errorText = errorContainer.querySelector('.stack-trace').textContent;
2101
- const textarea = document.createElement('textarea');
2102
- textarea.value = errorText;
2103
- document.body.appendChild(textarea);
2104
- textarea.select();
2105
- try {
2106
- const successful = document.execCommand('copy');
2107
- const originalText = button.textContent;
2108
- button.textContent = successful ? 'Copied!' : 'Failed to copy';
2109
- setTimeout(() => {
2110
- button.textContent = originalText;
2111
- }, 2000);
2112
- } catch (err) {
2113
- console.error('Failed to copy: ', err);
2114
- button.textContent = 'Failed to copy';
2115
- }
2009
+ // 1. Find the main error container, which should always be present.
2010
+ const errorContainer = button.closest('.step-error');
2011
+ if (!errorContainer) {
2012
+ console.error("Could not find '.step-error' container. The report's HTML structure might have changed.");
2013
+ return;
2014
+ }
2015
+
2016
+ let errorText;
2017
+
2018
+ // 2. First, try to find the preferred .stack-trace element (the "happy path").
2019
+ const stackTraceElement = errorContainer.querySelector('.stack-trace');
2020
+
2021
+ if (stackTraceElement) {
2022
+ // If it exists, use its text content. This handles standard assertion errors.
2023
+ errorText = stackTraceElement.textContent;
2024
+ } else {
2025
+ // 3. FALLBACK: If .stack-trace doesn't exist, this is likely an unstructured error.
2026
+ // We clone the container to avoid manipulating the live DOM or copying the button's own text.
2027
+ const clonedContainer = errorContainer.cloneNode(true);
2028
+
2029
+ // Remove the button from our clone before extracting the text.
2030
+ const buttonInClone = clonedContainer.querySelector('button');
2031
+ if (buttonInClone) {
2032
+ buttonInClone.remove();
2033
+ }
2034
+
2035
+ // Use the text content of the cleaned container as the fallback.
2036
+ errorText = clonedContainer.textContent;
2037
+ }
2038
+
2039
+ // 4. Proceed with the clipboard logic, ensuring text is not null and is trimmed.
2040
+ if (!errorText) {
2041
+ console.error('Could not extract error text.');
2042
+ button.textContent = 'Nothing to copy';
2043
+ setTimeout(() => { button.textContent = 'Copy Error'; }, 2000);
2044
+ return;
2045
+ }
2046
+
2047
+ const textarea = document.createElement('textarea');
2048
+ textarea.value = errorText.trim(); // Trim whitespace for a cleaner copy.
2049
+ textarea.style.position = 'fixed'; // Prevent screen scroll
2050
+ textarea.style.top = '-9999px';
2051
+ document.body.appendChild(textarea);
2052
+ textarea.select();
2053
+
2054
+ try {
2055
+ const successful = document.execCommand('copy');
2056
+ const originalText = button.textContent;
2057
+ button.textContent = successful ? 'Copied!' : 'Failed';
2058
+ setTimeout(() => {
2059
+ button.textContent = originalText;
2060
+ }, 2000);
2061
+ } catch (err) {
2062
+ console.error('Failed to copy: ', err);
2063
+ button.textContent = 'Failed';
2064
+ }
2065
+
2116
2066
  document.body.removeChild(textarea);
2117
2067
  }
2118
2068
  </script>