@arghajit/playwright-pulse-report 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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;
@@ -157,13 +157,13 @@ function sanitizeHTML(str) {
157
157
  "<": "<",
158
158
  ">": ">",
159
159
  '"': '"',
160
- "'": "'", // or '
160
+ "'": "'",
161
161
  };
162
162
  return replacements[match] || match;
163
163
  });
164
164
  }
165
165
  function capitalize(str) {
166
- if (!str) return ""; // Handle empty string
166
+ if (!str) return "";
167
167
  return str[0].toUpperCase() + str.slice(1).toLowerCase();
168
168
  }
169
169
  function formatPlaywrightError(error) {
@@ -171,22 +171,18 @@ function formatPlaywrightError(error) {
171
171
  return convertPlaywrightErrorToHTML(commandOutput);
172
172
  }
173
173
  function convertPlaywrightErrorToHTML(str) {
174
- return (
175
- str
176
- // Convert leading spaces to &nbsp; and tabs to &nbsp;&nbsp;&nbsp;&nbsp;
177
- .replace(/^(\s+)/gm, (match) =>
178
- match.replace(/ /g, "&nbsp;").replace(/\t/g, "&nbsp;&nbsp;")
179
- )
180
- // Color and style replacements
181
- .replace(/<red>/g, '<span style="color: red;">')
182
- .replace(/<green>/g, '<span style="color: green;">')
183
- .replace(/<dim>/g, '<span style="opacity: 0.6;">')
184
- .replace(/<intensity>/g, '<span style="font-weight: bold;">') // Changed to apply bold
185
- .replace(/<\/color>/g, "</span>")
186
- .replace(/<\/intensity>/g, "</span>")
187
- // Convert newlines to <br> after processing other replacements
188
- .replace(/\n/g, "<br>")
189
- );
174
+ if (!str) return "";
175
+ return str
176
+ .replace(/^(\s+)/gm, (match) =>
177
+ match.replace(/ /g, " ").replace(/\t/g, " ")
178
+ )
179
+ .replace(/<red>/g, '<span style="color: red;">')
180
+ .replace(/<green>/g, '<span style="color: green;">')
181
+ .replace(/<dim>/g, '<span style="opacity: 0.6;">')
182
+ .replace(/<intensity>/g, '<span style="font-weight: bold;">')
183
+ .replace(/<\/color>/g, "</span>")
184
+ .replace(/<\/intensity>/g, "</span>")
185
+ .replace(/\n/g, "<br>");
190
186
  }
191
187
  function formatDuration(ms, options = {}) {
192
188
  const {
@@ -227,19 +223,12 @@ function formatDuration(ms, options = {}) {
227
223
 
228
224
  const totalRawSeconds = numMs / MS_PER_SECOND;
229
225
 
230
- // Decision: Are we going to display hours or minutes?
231
- // This happens if the duration is inherently >= 1 minute OR
232
- // if it's < 1 minute but ceiling the seconds makes it >= 1 minute.
233
226
  if (
234
227
  totalRawSeconds < SECONDS_PER_MINUTE &&
235
228
  Math.ceil(totalRawSeconds) < SECONDS_PER_MINUTE
236
229
  ) {
237
- // Strictly seconds-only display, use precision.
238
230
  return `${totalRawSeconds.toFixed(validPrecision)}s`;
239
231
  } else {
240
- // Display will include minutes and/or hours, or seconds round up to a minute.
241
- // Seconds part should be an integer (ceiling).
242
- // Round the total milliseconds UP to the nearest full second.
243
232
  const totalMsRoundedUpToSecond =
244
233
  Math.ceil(numMs / MS_PER_SECOND) * MS_PER_SECOND;
245
234
 
@@ -251,21 +240,15 @@ function formatDuration(ms, options = {}) {
251
240
  const m = Math.floor(remainingMs / (MS_PER_SECOND * SECONDS_PER_MINUTE));
252
241
  remainingMs %= MS_PER_SECOND * SECONDS_PER_MINUTE;
253
242
 
254
- const s = Math.floor(remainingMs / MS_PER_SECOND); // This will be an integer
243
+ const s = Math.floor(remainingMs / MS_PER_SECOND);
255
244
 
256
245
  const parts = [];
257
246
  if (h > 0) {
258
247
  parts.push(`${h}h`);
259
248
  }
260
-
261
- // Show minutes if:
262
- // - hours are present (e.g., "1h 0m 5s")
263
- // - OR minutes themselves are > 0 (e.g., "5m 10s")
264
- // - OR the original duration was >= 1 minute (ensures "1m 0s" for 60000ms)
265
249
  if (h > 0 || m > 0 || numMs >= MS_PER_SECOND * SECONDS_PER_MINUTE) {
266
250
  parts.push(`${m}m`);
267
251
  }
268
-
269
252
  parts.push(`${s}s`);
270
253
 
271
254
  return parts.join(" ");
@@ -1283,6 +1266,14 @@ function generateSuitesWidget(suitesData) {
1283
1266
  </div>
1284
1267
  </div>`;
1285
1268
  }
1269
+ function getAttachmentIcon(contentType) {
1270
+ if (contentType.includes("pdf")) return "📄";
1271
+ if (contentType.includes("json")) return "{ }";
1272
+ if (contentType.includes("html") || contentType.includes("xml")) return "</>";
1273
+ if (contentType.includes("csv")) return "📊";
1274
+ if (contentType.startsWith("text/")) return "📝";
1275
+ return "📎";
1276
+ }
1286
1277
  function generateHTML(reportData, trendData = null) {
1287
1278
  const { run, results } = reportData;
1288
1279
  const suitesData = getSuitesData(reportData.results || []);
@@ -1389,16 +1380,6 @@ function generateHTML(reportData, trendData = null) {
1389
1380
  .join("");
1390
1381
  };
1391
1382
 
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
1383
  return `
1403
1384
  <div class="test-case" data-status="${
1404
1385
  test.status
@@ -1428,6 +1409,11 @@ function generateHTML(reportData, trendData = null) {
1428
1409
  </div>
1429
1410
  <div class="test-case-content" style="display: none;">
1430
1411
  <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
1412
+ <p><strong>Test run Worker ID:</strong> ${sanitizeHTML(
1413
+ test.workerId
1414
+ )} [<strong>Total No. of Workers:</strong> ${sanitizeHTML(
1415
+ test.totalWorkers
1416
+ )}]</p>
1431
1417
  ${
1432
1418
  test.errorMessage
1433
1419
  ? `<div class="test-error-summary">${formatPlaywrightError(
@@ -1459,119 +1445,152 @@ function generateHTML(reportData, trendData = null) {
1459
1445
  <div class="steps-list">${generateStepsHTML(test.steps)}</div>
1460
1446
  ${
1461
1447
  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>`
1448
+ ? `<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;">${formatPlaywrightError(
1449
+ test.stdout.map((line) => sanitizeHTML(line)).join("\n")
1450
+ )}</pre></div>`
1465
1451
  : ""
1466
1452
  }
1467
1453
  ${
1468
1454
  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>`
1455
+ ? `<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;">${formatPlaywrightError(
1456
+ test.stderr.map((line) => sanitizeHTML(line)).join("\n")
1457
+ )}</pre></div>`
1472
1458
  : ""
1473
1459
  }
1474
1460
  ${
1475
1461
  test.screenshots && test.screenshots.length > 0
1476
1462
  ? `
1477
- <div class="attachments-section">
1478
- <h4>Screenshots</h4>
1479
- <div class="attachments-grid">
1480
- ${test.screenshots
1481
- .map(
1482
- (screenshot, index) => `
1483
- <div class="attachment-item">
1484
- <img src="${screenshot}" alt="Screenshot">
1485
- <div class="attachment-info">
1486
- <div class="trace-actions">
1487
- <a href="${screenshot}" target="_blank" class="view-full">View Full Image</a>
1488
- <a href="${screenshot}" target="_blank" download="screenshot-${Date.now()}-${index}-${Math.random()
1489
- .toString(36)
1490
- .substring(2, 7)}.png">Download</a>
1463
+ <div class="attachments-section">
1464
+ <h4>Screenshots</h4>
1465
+ <div class="attachments-grid">
1466
+ ${test.screenshots
1467
+ .map(
1468
+ (screenshot, index) => `
1469
+ <div class="attachment-item">
1470
+ <img src="${screenshot}" alt="Screenshot ${index + 1}">
1471
+ <div class="attachment-info">
1472
+ <div class="trace-actions">
1473
+ <a href="${screenshot}" target="_blank" class="view-full">View Full Image</a>
1474
+ <a href="${screenshot}" target="_blank" download="screenshot-${Date.now()}-${index}.png">Download</a>
1475
+ </div>
1476
+ </div>
1477
+ </div>
1478
+ `
1479
+ )
1480
+ .join("")}
1481
+ </div>
1491
1482
  </div>
1492
- </div>
1493
- </div>
1494
- `
1495
- )
1496
- .join("")}
1497
- </div>
1498
- </div>
1499
- `
1483
+ `
1500
1484
  : ""
1501
1485
  }
1502
1486
  ${
1503
- test.videoPath
1504
- ? `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${(() => {
1505
- // Videos
1506
- const videos = Array.isArray(test.videoPath)
1507
- ? test.videoPath
1508
- : [test.videoPath];
1509
- const mimeTypes = {
1510
- mp4: "video/mp4",
1511
- webm: "video/webm",
1512
- ogg: "video/ogg",
1513
- mov: "video/quicktime",
1514
- avi: "video/x-msvideo",
1515
- };
1516
- return videos
1517
- .map((video, index) => {
1518
- const videoUrl =
1519
- typeof video === "object" ? video.url || "" : video;
1520
- const videoName =
1521
- typeof video === "object"
1522
- ? video.name || `Video ${index + 1}`
1523
- : `Video ${index + 1}`;
1524
- const fileExtension = String(videoUrl)
1525
- .split(".")
1526
- .pop()
1527
- .toLowerCase();
1528
- const mimeType = mimeTypes[fileExtension] || "video/mp4";
1529
- return `<div class="attachment-item"><video controls width="100%" height="auto" title="${sanitizeHTML(
1530
- videoName
1531
- )}"><source src="${sanitizeHTML(
1532
- videoUrl
1533
- )}" type="${mimeType}">Your browser does not support the video tag.</video><div class="attachment-info"><div class="trace-actions"><a href="${sanitizeHTML(
1534
- videoUrl
1535
- )}" target="_blank" download="${sanitizeHTML(
1536
- videoName
1537
- )}.${fileExtension}">Download</a></div></div></div>`;
1538
- })
1539
- .join("");
1540
- })()}</div></div>`
1487
+ test.videoPath && test.videoPath.length > 0
1488
+ ? `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${test.videoPath
1489
+ .map((videoUrl, index) => {
1490
+ const fileExtension = String(videoUrl)
1491
+ .split(".")
1492
+ .pop()
1493
+ .toLowerCase();
1494
+ const mimeType =
1495
+ {
1496
+ mp4: "video/mp4",
1497
+ webm: "video/webm",
1498
+ ogg: "video/ogg",
1499
+ mov: "video/quicktime",
1500
+ avi: "video/x-msvideo",
1501
+ }[fileExtension] || "video/mp4";
1502
+ return `<div class="attachment-item video-item">
1503
+ <video controls width="100%" height="auto" title="Video ${
1504
+ index + 1
1505
+ }">
1506
+ <source src="${sanitizeHTML(
1507
+ videoUrl
1508
+ )}" type="${mimeType}">
1509
+ Your browser does not support the video tag.
1510
+ </video>
1511
+ <div class="attachment-info">
1512
+ <div class="trace-actions">
1513
+ <a href="${sanitizeHTML(
1514
+ videoUrl
1515
+ )}" target="_blank" download="video-${Date.now()}-${index}.${fileExtension}">Download</a>
1516
+ </div>
1517
+ </div>
1518
+ </div>`;
1519
+ })
1520
+ .join("")}</div></div>`
1541
1521
  : ""
1542
1522
  }
1543
1523
  ${
1544
1524
  test.tracePath
1545
- ? `<div class="attachments-section"><h4>Trace Files</h4><div class="attachments-grid">${(() => {
1546
- // Traces
1547
- const traces = Array.isArray(test.tracePath)
1548
- ? test.tracePath
1549
- : [test.tracePath];
1550
- return traces
1551
- .map((trace, index) => {
1552
- const traceUrl =
1553
- typeof trace === "object" ? trace.url || "" : trace;
1554
- const traceName =
1555
- typeof trace === "object"
1556
- ? trace.name || `Trace ${index + 1}`
1557
- : `Trace ${index + 1}`;
1558
- const traceFileName = String(traceUrl).split("/").pop();
1559
- return `<div class="attachment-item"><div class="trace-preview"><span class="trace-icon">📄</span><span class="trace-name">${sanitizeHTML(
1560
- traceName
1561
- )}</span></div><div class="attachment-info"><div class="trace-actions"><a href="${sanitizeHTML(
1562
- traceUrl
1563
- )}" target="_blank" download="${sanitizeHTML(
1564
- traceFileName
1565
- )}" class="download-trace">Download</a></div></div></div>`;
1566
- })
1567
- .join("");
1568
- })()}</div></div>`
1525
+ ? `
1526
+ <div class="attachments-section">
1527
+ <h4>Trace Files</h4>
1528
+ <div class="attachments-grid">
1529
+ <div class="attachment-item trace-item">
1530
+ <div class="trace-preview">
1531
+ <span class="trace-icon">📄</span>
1532
+ <span class="trace-name">${sanitizeHTML(
1533
+ path.basename(test.tracePath)
1534
+ )}</span>
1535
+ </div>
1536
+ <div class="attachment-info">
1537
+ <div class="trace-actions">
1538
+ <a href="${sanitizeHTML(
1539
+ test.tracePath
1540
+ )}" target="_blank" download="${sanitizeHTML(
1541
+ path.basename(test.tracePath)
1542
+ )}" class="download-trace">Download Trace</a>
1543
+ </div>
1544
+ </div>
1545
+ </div>
1546
+ </div>
1547
+ </div>
1548
+ `
1549
+ : ""
1550
+ }
1551
+ ${
1552
+ test.attachments && test.attachments.length > 0
1553
+ ? `
1554
+ <div class="attachments-section">
1555
+ <h4>Other Attachments</h4>
1556
+ <div class="attachments-grid">
1557
+ ${test.attachments
1558
+ .map(
1559
+ (attachment) => `
1560
+ <div class="attachment-item generic-attachment">
1561
+ <div class="attachment-icon">${getAttachmentIcon(
1562
+ attachment.contentType
1563
+ )}</div>
1564
+ <div class="attachment-caption">
1565
+ <span class="attachment-name" title="${sanitizeHTML(
1566
+ attachment.name
1567
+ )}">${sanitizeHTML(attachment.name)}</span>
1568
+ <span class="attachment-type">${sanitizeHTML(
1569
+ attachment.contentType
1570
+ )}</span>
1571
+ </div>
1572
+ <div class="attachment-info">
1573
+ <div class="trace-actions">
1574
+ <a href="${sanitizeHTML(
1575
+ attachment.path
1576
+ )}" target="_blank" download="${sanitizeHTML(
1577
+ attachment.name
1578
+ )}" class="download-trace">Download</a>
1579
+ </div>
1580
+ </div>
1581
+ </div>
1582
+ `
1583
+ )
1584
+ .join("")}
1585
+ </div>
1586
+ </div>
1587
+ `
1569
1588
  : ""
1570
1589
  }
1571
1590
  ${
1572
1591
  test.codeSnippet
1573
- ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
1574
- test.codeSnippet
1592
+ ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${formatPlaywrightError(
1593
+ sanitizeHTML(test.codeSnippet)
1575
1594
  )}</code></pre></div>`
1576
1595
  : ""
1577
1596
  }
@@ -1601,14 +1620,11 @@ function generateHTML(reportData, trendData = null) {
1601
1620
  }
1602
1621
  .trend-chart-container, .test-history-trend div[id^="testHistoryChart-"] { min-height: 100px; }
1603
1622
  .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); }
1604
-
1605
- /* General Highcharts styling */
1606
1623
  .highcharts-background { fill: transparent; }
1607
1624
  .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
1608
1625
  .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
1609
1626
  .highcharts-axis-title { fill: var(--text-color) !important; }
1610
1627
  .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; }
1611
-
1612
1628
  body { font-family: var(--font-family); margin: 0; background-color: var(--background-color); color: var(--text-color); line-height: 1.65; font-size: 16px; }
1613
1629
  .container { padding: 30px; border-radius: var(--border-radius); box-shadow: var(--box-shadow); background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec); }
1614
1630
  .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; }
@@ -1704,11 +1720,19 @@ function generateHTML(reportData, trendData = null) {
1704
1720
  .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; }
1705
1721
  .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
1706
1722
  .attachment-item img { width: 100%; height: 180px; object-fit: cover; display: block; border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease; }
1723
+ .attachment-info { padding: 12px; margin-top: auto; background-color: #fafafa;}
1707
1724
  .attachment-item a:hover img { opacity: 0.85; }
1708
1725
  .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); }
1709
1726
  .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
1710
1727
  .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
1711
1728
  .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;}
1729
+ .trace-actions { display: flex; justify-content: center; }
1730
+ .trace-actions a { text-decoration: none; color: var(--primary-color); font-weight: 500; font-size: 0.9em; }
1731
+ .generic-attachment { text-align: center; padding: 1rem; justify-content: center; }
1732
+ .attachment-icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
1733
+ .attachment-caption { display: flex; flex-direction: column; }
1734
+ .attachment-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1735
+ .attachment-type { font-size: 0.8rem; color: var(--text-color-secondary); }
1712
1736
  .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
1713
1737
  .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;}
1714
1738
  .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
@@ -2035,24 +2059,64 @@ function generateHTML(reportData, trendData = null) {
2035
2059
  }
2036
2060
  document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
2037
2061
 
2038
- function copyErrorToClipboard(button) {
2039
- const errorContainer = button.closest('.step-error');
2040
- const errorText = errorContainer.querySelector('.stack-trace').textContent;
2041
- const textarea = document.createElement('textarea');
2042
- textarea.value = errorText;
2043
- document.body.appendChild(textarea);
2044
- textarea.select();
2045
- try {
2046
- const successful = document.execCommand('copy');
2047
- const originalText = button.textContent;
2048
- button.textContent = successful ? 'Copied!' : 'Failed to copy';
2049
- setTimeout(() => {
2050
- button.textContent = originalText;
2051
- }, 2000);
2052
- } catch (err) {
2053
- console.error('Failed to copy: ', err);
2054
- button.textContent = 'Failed to copy';
2055
- }
2062
+ function copyErrorToClipboard(button) {
2063
+ // 1. Find the main error container, which should always be present.
2064
+ const errorContainer = button.closest('.step-error');
2065
+ if (!errorContainer) {
2066
+ console.error("Could not find '.step-error' container. The report's HTML structure might have changed.");
2067
+ return;
2068
+ }
2069
+
2070
+ let errorText;
2071
+
2072
+ // 2. First, try to find the preferred .stack-trace element (the "happy path").
2073
+ const stackTraceElement = errorContainer.querySelector('.stack-trace');
2074
+
2075
+ if (stackTraceElement) {
2076
+ // If it exists, use its text content. This handles standard assertion errors.
2077
+ errorText = stackTraceElement.textContent;
2078
+ } else {
2079
+ // 3. FALLBACK: If .stack-trace doesn't exist, this is likely an unstructured error.
2080
+ // We clone the container to avoid manipulating the live DOM or copying the button's own text.
2081
+ const clonedContainer = errorContainer.cloneNode(true);
2082
+
2083
+ // Remove the button from our clone before extracting the text.
2084
+ const buttonInClone = clonedContainer.querySelector('button');
2085
+ if (buttonInClone) {
2086
+ buttonInClone.remove();
2087
+ }
2088
+
2089
+ // Use the text content of the cleaned container as the fallback.
2090
+ errorText = clonedContainer.textContent;
2091
+ }
2092
+
2093
+ // 4. Proceed with the clipboard logic, ensuring text is not null and is trimmed.
2094
+ if (!errorText) {
2095
+ console.error('Could not extract error text.');
2096
+ button.textContent = 'Nothing to copy';
2097
+ setTimeout(() => { button.textContent = 'Copy Error'; }, 2000);
2098
+ return;
2099
+ }
2100
+
2101
+ const textarea = document.createElement('textarea');
2102
+ textarea.value = errorText.trim(); // Trim whitespace for a cleaner copy.
2103
+ textarea.style.position = 'fixed'; // Prevent screen scroll
2104
+ textarea.style.top = '-9999px';
2105
+ document.body.appendChild(textarea);
2106
+ textarea.select();
2107
+
2108
+ try {
2109
+ const successful = document.execCommand('copy');
2110
+ const originalText = button.textContent;
2111
+ button.textContent = successful ? 'Copied!' : 'Failed';
2112
+ setTimeout(() => {
2113
+ button.textContent = originalText;
2114
+ }, 2000);
2115
+ } catch (err) {
2116
+ console.error('Failed to copy: ', err);
2117
+ button.textContent = 'Failed';
2118
+ }
2119
+
2056
2120
  document.body.removeChild(textarea);
2057
2121
  }
2058
2122
  </script>