@arghajit/playwright-pulse-report 0.3.0 → 0.3.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.
@@ -394,6 +394,7 @@ function generateTestTrendsChart(trendData) {
394
394
  </script>
395
395
  `;
396
396
  }
397
+ const accentColorAltRGB = "255, 152, 0"; // Assuming var(--accent-color-alt) is Orange #FF9800
397
398
  /**
398
399
  * Generates HTML and JavaScript for a Highcharts area chart to display test duration trends.
399
400
  * @param {object} trendData Data for duration trends.
@@ -413,8 +414,6 @@ function generateDurationTrendChart(trendData) {
413
414
  )}`;
414
415
  const runs = trendData.overall;
415
416
 
416
- const accentColorAltRGB = "255, 152, 0"; // Assuming var(--accent-color-alt) is Orange #FF9800
417
-
418
417
  const chartDataString = JSON.stringify(runs.map((run) => run.duration));
419
418
  const categoriesString = JSON.stringify(runs.map((run, i) => `Run ${i + 1}`));
420
419
  const runsForTooltip = runs.map((r) => ({
@@ -777,6 +776,9 @@ function generateEnvironmentDashboard(environment, dashboardHeight = 600) {
777
776
  const cardHeight = Math.floor(dashboardHeight * 0.44);
778
777
  const cardContentPadding = 16; // px
779
778
 
779
+ // Logic for Run Context
780
+ const runContext = process.env.CI ? "CI" : "Local Test";
781
+
780
782
  return `
781
783
  <div class="environment-dashboard-wrapper" id="${dashboardId}">
782
784
  <style>
@@ -820,6 +822,20 @@ gap: 20px;
820
822
  font-size: 14px;
821
823
  }
822
824
 
825
+ /* Mobile Responsiveness */
826
+ @media (max-width: 768px) {
827
+ .environment-dashboard-wrapper {
828
+ grid-template-columns: 1fr; /* Stack columns on mobile */
829
+ grid-template-rows: auto;
830
+ padding: 16px;
831
+ height: auto !important; /* Allow height to grow */
832
+ }
833
+ .env-card {
834
+ height: auto !important; /* Allow cards to grow based on content */
835
+ min-height: 200px;
836
+ }
837
+ }
838
+
823
839
  .env-dashboard-header {
824
840
  grid-column: 1 / -1;
825
841
  display: flex;
@@ -828,6 +844,8 @@ align-items: center;
828
844
  border-bottom: 1px solid var(--border-color);
829
845
  padding-bottom: 16px;
830
846
  margin-bottom: 8px;
847
+ flex-wrap: wrap; /* Allow wrapping header items */
848
+ gap: 10px;
831
849
  }
832
850
 
833
851
  .env-dashboard-title {
@@ -1087,7 +1105,7 @@ border-color: var(--border-color);
1087
1105
  </div>
1088
1106
  <div class="env-detail-row">
1089
1107
  <span class="env-detail-label">Run Context</span>
1090
- <span class="env-detail-value">CI/Local Test</span>
1108
+ <span class="env-detail-value">${runContext}</span>
1091
1109
  </div>
1092
1110
  </div>
1093
1111
  </div>
@@ -1781,6 +1799,409 @@ function generateAIFailureAnalyzerTab(results) {
1781
1799
  </div>
1782
1800
  `;
1783
1801
  }
1802
+ /**
1803
+ * Generates a area chart showing the total duration per spec file.
1804
+ * The chart is lazy-loaded and rendered with Highcharts when scrolled into view.
1805
+ *
1806
+ * @param {Array<object>} results - Array of test result objects.
1807
+ * @returns {string} HTML string containing the chart container and lazy-loading script.
1808
+ */
1809
+ function generateSpecDurationChart(results) {
1810
+ if (!results || results.length === 0)
1811
+ return '<div class="no-data">No results available.</div>';
1812
+
1813
+ const specDurations = {};
1814
+ results.forEach((test) => {
1815
+ // Use the dedicated 'spec_file' key
1816
+ const fileName = test.spec_file || "Unknown File";
1817
+
1818
+ if (!specDurations[fileName]) specDurations[fileName] = 0;
1819
+ specDurations[fileName] += test.duration;
1820
+ });
1821
+
1822
+ const categories = Object.keys(specDurations);
1823
+ // We map 'name' here, which we will use in the tooltip later
1824
+ const data = categories.map((cat) => ({
1825
+ y: specDurations[cat],
1826
+ name: cat,
1827
+ }));
1828
+
1829
+ if (categories.length === 0)
1830
+ return '<div class="no-data">No spec data found.</div>';
1831
+
1832
+ const chartId = `specDurChart-${Date.now()}-${Math.random()
1833
+ .toString(36)
1834
+ .substring(2, 7)}`;
1835
+ const renderFunctionName = `renderSpecDurChart_${chartId.replace(/-/g, "_")}`;
1836
+
1837
+ const categoriesStr = JSON.stringify(categories);
1838
+ const dataStr = JSON.stringify(data);
1839
+
1840
+ return `
1841
+ <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}">
1842
+ <div class="no-data">Loading Spec Duration Chart...</div>
1843
+ </div>
1844
+ <script>
1845
+ window.${renderFunctionName} = function() {
1846
+ const chartContainer = document.getElementById('${chartId}');
1847
+ if (!chartContainer) return;
1848
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
1849
+ try {
1850
+ chartContainer.innerHTML = '';
1851
+ Highcharts.chart('${chartId}', {
1852
+ chart: { type: 'area', height: 350, backgroundColor: 'transparent' },
1853
+ title: { text: null },
1854
+ xAxis: {
1855
+ categories: ${categoriesStr},
1856
+ visible: false, // 1. HIDE THE X-AXIS
1857
+ title: { text: null },
1858
+ crosshair: true
1859
+ },
1860
+ yAxis: {
1861
+ min: 0,
1862
+ title: { text: 'Total Duration', style: { color: 'var(--text-color)' } },
1863
+ labels: { formatter: function() { return formatDuration(this.value); }, style: { color: 'var(--text-color-secondary)' } }
1864
+ },
1865
+ legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom', itemStyle: { fontSize: '12px', color: 'var(--text-color)' }},
1866
+ plotOptions: { area: { lineWidth: 2.5, states: { hover: { lineWidthPlus: 0 } }, threshold: null }},
1867
+ tooltip: {
1868
+ shared: true,
1869
+ useHTML: true,
1870
+ backgroundColor: 'rgba(10,10,10,0.92)',
1871
+ borderColor: 'rgba(10,10,10,0.92)',
1872
+ style: { color: '#f5f5f5' },
1873
+ formatter: function() {
1874
+ const point = this.points ? this.points[0].point : this.point;
1875
+ const color = point.color || point.series.color;
1876
+
1877
+ // 2. FIX: Use 'point.name' instead of 'this.x' to get the actual filename
1878
+ return '<span style="color:' + color + '">●</span> <b>File: ' + point.name + '</b><br/>' +
1879
+ 'Duration: <b>' + formatDuration(this.y) + '</b>';
1880
+ }
1881
+ },
1882
+ series: [{
1883
+ name: 'Duration',
1884
+ data: ${dataStr},
1885
+ color: 'var(--accent-color-alt)',
1886
+ type: 'area',
1887
+ marker: { symbol: 'circle', enabled: true, radius: 4, states: { hover: { radius: 6, lineWidthPlus: 0 } } },
1888
+ fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, 'rgba(${accentColorAltRGB}, 0.4)'], [1, 'rgba(${accentColorAltRGB}, 0.05)']] },
1889
+ lineWidth: 2.5
1890
+ }],
1891
+ credits: { enabled: false }
1892
+ });
1893
+ } catch (e) { console.error("Error rendering spec chart:", e); }
1894
+ }
1895
+ };
1896
+ </script>
1897
+ `;
1898
+ }
1899
+ /**
1900
+ * Generates a vertical bar chart showing the total duration of each test describe block.
1901
+ * Tests without a describe block or with "n/a" / empty describe names are ignored.
1902
+ * @param {Array<object>} results - Array of test result objects.
1903
+ * @returns {string} HTML string containing the chart container and lazy-loading script.
1904
+ */
1905
+ function generateDescribeDurationChart(results) {
1906
+ if (!results || results.length === 0)
1907
+ return '<div class="no-data">Seems like there is test describe block available in the executed test suite.</div>';
1908
+
1909
+ const describeMap = new Map();
1910
+ let foundAnyDescribe = false;
1911
+
1912
+ results.forEach((test) => {
1913
+ if (test.describe) {
1914
+ const describeName = test.describe;
1915
+ // Filter out invalid describe blocks
1916
+ if (
1917
+ !describeName ||
1918
+ describeName.trim().toLowerCase() === "n/a" ||
1919
+ describeName.trim() === ""
1920
+ ) {
1921
+ return;
1922
+ }
1923
+
1924
+ foundAnyDescribe = true;
1925
+ const fileName = test.spec_file || "Unknown File";
1926
+ const key = fileName + "::" + describeName;
1927
+
1928
+ if (!describeMap.has(key)) {
1929
+ describeMap.set(key, {
1930
+ duration: 0,
1931
+ file: fileName,
1932
+ describe: describeName,
1933
+ });
1934
+ }
1935
+ describeMap.get(key).duration += test.duration;
1936
+ }
1937
+ });
1938
+
1939
+ if (!foundAnyDescribe) {
1940
+ return '<div class="no-data">No valid test describe blocks found.</div>';
1941
+ }
1942
+
1943
+ const categories = [];
1944
+ const data = [];
1945
+
1946
+ for (const [key, val] of describeMap.entries()) {
1947
+ categories.push(val.describe);
1948
+ data.push({
1949
+ y: val.duration,
1950
+ name: val.describe,
1951
+ custom: {
1952
+ fileName: val.file,
1953
+ describeName: val.describe,
1954
+ },
1955
+ });
1956
+ }
1957
+
1958
+ const chartId = `descDurChart-${Date.now()}-${Math.random()
1959
+ .toString(36)
1960
+ .substring(2, 7)}`;
1961
+ const renderFunctionName = `renderDescDurChart_${chartId.replace(/-/g, "_")}`;
1962
+
1963
+ const categoriesStr = JSON.stringify(categories);
1964
+ const dataStr = JSON.stringify(data);
1965
+
1966
+ return `
1967
+ <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}">
1968
+ <div class="no-data">Loading Describe Duration Chart...</div>
1969
+ </div>
1970
+ <script>
1971
+ window.${renderFunctionName} = function() {
1972
+ const chartContainer = document.getElementById('${chartId}');
1973
+ if (!chartContainer) return;
1974
+ if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') {
1975
+ try {
1976
+ chartContainer.innerHTML = '';
1977
+ Highcharts.chart('${chartId}', {
1978
+ chart: {
1979
+ type: 'column', // 1. CHANGED: 'bar' -> 'column' for vertical bars
1980
+ height: 400, // 2. CHANGED: Fixed height works better for vertical charts
1981
+ backgroundColor: 'transparent'
1982
+ },
1983
+ title: { text: null },
1984
+ xAxis: {
1985
+ categories: ${categoriesStr},
1986
+ visible: false, // Hidden as requested
1987
+ title: { text: null },
1988
+ crosshair: true
1989
+ },
1990
+ yAxis: {
1991
+ min: 0,
1992
+ title: { text: 'Total Duration', style: { color: 'var(--text-color)' } },
1993
+ labels: { formatter: function() { return formatDuration(this.value); }, style: { color: 'var(--text-color-secondary)' } }
1994
+ },
1995
+ legend: { enabled: false },
1996
+ plotOptions: {
1997
+ series: {
1998
+ borderRadius: 4,
1999
+ borderWidth: 0,
2000
+ states: { hover: { brightness: 0.1 }}
2001
+ },
2002
+ column: { pointPadding: 0.2, groupPadding: 0.1 } // Adjust spacing for columns
2003
+ },
2004
+ tooltip: {
2005
+ shared: true,
2006
+ useHTML: true,
2007
+ backgroundColor: 'rgba(10,10,10,0.92)',
2008
+ borderColor: 'rgba(10,10,10,0.92)',
2009
+ style: { color: '#f5f5f5' },
2010
+ formatter: function() {
2011
+ const point = this.points ? this.points[0].point : this.point;
2012
+ const file = (point.custom && point.custom.fileName) ? point.custom.fileName : 'Unknown';
2013
+ const desc = point.name || 'Unknown';
2014
+ const color = point.color || point.series.color;
2015
+
2016
+ return '<span style="color:' + color + '">●</span> <b>Describe: ' + desc + '</b><br/>' +
2017
+ '<span style="opacity: 0.8; font-size: 0.9em; color: #ddd;">File: ' + file + '</span><br/>' +
2018
+ 'Duration: <b>' + formatDuration(point.y) + '</b>';
2019
+ }
2020
+ },
2021
+ series: [{
2022
+ name: 'Duration',
2023
+ data: ${dataStr},
2024
+ color: 'var(--accent-color-alt)',
2025
+ }],
2026
+ credits: { enabled: false }
2027
+ });
2028
+ } catch (e) { console.error("Error rendering describe chart:", e); }
2029
+ }
2030
+ };
2031
+ </script>
2032
+ `;
2033
+ }
2034
+ /**
2035
+ * Generates a stacked column chart showing test results distributed by severity.
2036
+ * Matches dimensions of the System Environment section (~600px).
2037
+ * Lazy-loaded for performance.
2038
+ */
2039
+ function generateSeverityDistributionChart(results) {
2040
+ if (!results || results.length === 0) {
2041
+ return '<div class="trend-chart" style="height: 600px;"><div class="no-data">No results available for severity distribution.</div></div>';
2042
+ }
2043
+
2044
+ const severityLevels = ["Critical", "High", "Medium", "Low", "Minor"];
2045
+ const data = {
2046
+ passed: [0, 0, 0, 0, 0],
2047
+ failed: [0, 0, 0, 0, 0],
2048
+ skipped: [0, 0, 0, 0, 0],
2049
+ };
2050
+
2051
+ results.forEach((test) => {
2052
+ const sev = test.severity || "Medium";
2053
+ const status = String(test.status).toLowerCase();
2054
+
2055
+ let index = severityLevels.indexOf(sev);
2056
+ if (index === -1) index = 2; // Default to Medium
2057
+
2058
+ if (status === "passed") {
2059
+ data.passed[index]++;
2060
+ } else if (
2061
+ status === "failed" ||
2062
+ status === "timedout" ||
2063
+ status === "interrupted"
2064
+ ) {
2065
+ data.failed[index]++;
2066
+ } else {
2067
+ data.skipped[index]++;
2068
+ }
2069
+ });
2070
+
2071
+ const chartId = `sevDistChart-${Date.now()}-${Math.random()
2072
+ .toString(36)
2073
+ .substring(2, 7)}`;
2074
+ const renderFunctionName = `renderSevDistChart_${chartId.replace(/-/g, "_")}`;
2075
+
2076
+ const seriesData = [
2077
+ { name: "Passed", data: data.passed, color: "var(--success-color)" },
2078
+ { name: "Failed", data: data.failed, color: "var(--danger-color)" },
2079
+ { name: "Skipped", data: data.skipped, color: "var(--warning-color)" },
2080
+ ];
2081
+
2082
+ const seriesDataStr = JSON.stringify(seriesData);
2083
+ const categoriesStr = JSON.stringify(severityLevels);
2084
+
2085
+ return `
2086
+ <div class="trend-chart" style="height: 600px; padding: 28px; box-sizing: border-box;">
2087
+ <h3 class="chart-title-header">Severity Distribution</h3>
2088
+ <div id="${chartId}" class="lazy-load-chart" data-render-function-name="${renderFunctionName}" style="width: 100%; height: 100%;">
2089
+ <div class="no-data">Loading Severity Chart...</div>
2090
+ </div>
2091
+ <script>
2092
+ window.${renderFunctionName} = function() {
2093
+ const chartContainer = document.getElementById('${chartId}');
2094
+ if (!chartContainer) return;
2095
+
2096
+ if (typeof Highcharts !== 'undefined') {
2097
+ try {
2098
+ chartContainer.innerHTML = '';
2099
+ Highcharts.chart('${chartId}', {
2100
+ chart: { type: 'column', backgroundColor: 'transparent' },
2101
+ title: { text: null },
2102
+ xAxis: {
2103
+ categories: ${categoriesStr},
2104
+ crosshair: true,
2105
+ labels: { style: { color: 'var(--text-color-secondary)' } }
2106
+ },
2107
+ yAxis: {
2108
+ min: 0,
2109
+ title: { text: 'Test Count', style: { color: 'var(--text-color)' } },
2110
+ stackLabels: { enabled: true, style: { fontWeight: 'bold', color: 'var(--text-color)' } },
2111
+ labels: { style: { color: 'var(--text-color-secondary)' } }
2112
+ },
2113
+ legend: {
2114
+ itemStyle: { color: 'var(--text-color)' }
2115
+ },
2116
+ tooltip: {
2117
+ shared: true,
2118
+ useHTML: true,
2119
+ backgroundColor: 'rgba(10,10,10,0.92)',
2120
+ style: { color: '#f5f5f5' },
2121
+ formatter: function() {
2122
+ // Custom formatter to HIDE 0 values
2123
+ let tooltip = '';
2124
+ let hasItems = false;
2125
+
2126
+ this.points.forEach(point => {
2127
+ if (point.y > 0) { // ONLY show if count > 0
2128
+ tooltip += '<span style="color:' + point.series.color + '">●</span> ' +
2129
+ point.series.name + ': <b>' + point.y + '</b><br/>';
2130
+ hasItems = true;
2131
+ }
2132
+ });
2133
+
2134
+ if (!hasItems) return false; // Hide tooltip entirely if no data
2135
+
2136
+ // Calculate total from visible points to ensure accuracy or use stackTotal
2137
+ tooltip += 'Total: ' + this.points[0].total;
2138
+ return tooltip;
2139
+ }
2140
+ },
2141
+ plotOptions: {
2142
+ column: {
2143
+ stacking: 'normal',
2144
+ dataLabels: {
2145
+ enabled: true,
2146
+ color: '#fff',
2147
+ style: { textOutline: 'none' },
2148
+ formatter: function() {
2149
+ return (this.y > 0) ? this.y : null; // Hide 0 labels on chart bars
2150
+ }
2151
+ },
2152
+ borderRadius: 3
2153
+ }
2154
+ },
2155
+ series: ${seriesDataStr},
2156
+ credits: { enabled: false }
2157
+ });
2158
+ } catch(e) {
2159
+ console.error("Error rendering severity chart:", e);
2160
+ chartContainer.innerHTML = '<div class="no-data">Error rendering chart.</div>';
2161
+ }
2162
+ }
2163
+ };
2164
+ </script>
2165
+ </div>
2166
+ `;
2167
+ }
2168
+ /**
2169
+ * Helper to generate Lazy Media HTML using the Script Tag pattern.
2170
+ * This prevents the browser from parsing massive Base64 strings on page load.
2171
+ */
2172
+ function createLazyMedia(base64Data, mimeType, type, index, filename) {
2173
+ const uniqueId = `media-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2174
+ const dataUri = `data:${mimeType};base64,${base64Data}`;
2175
+ // Store heavy data in a non-rendering script tag
2176
+ const storage = `<script type="text/plain" id="data-${uniqueId}">${dataUri}</script>`;
2177
+
2178
+ let mediaTag = '';
2179
+ let btnText = '';
2180
+ let icon = '';
2181
+
2182
+ if (type === 'video') {
2183
+ mediaTag = `<video id="${uniqueId}" controls style="display:none; width: 100%; aspect-ratio: 16/9; margin-bottom: 10px;"></video>`;
2184
+ btnText = '▶ Load Video';
2185
+ } else {
2186
+ mediaTag = `<img id="${uniqueId}" alt="Screenshot ${index}" style="display:none; width: 100%; aspect-ratio: 4/3; object-fit: cover; border-bottom: 1px solid var(--border-color);" />`;
2187
+ btnText = '📷 Load Image';
2188
+ }
2189
+
2190
+ return `
2191
+ <div class="attachment-item ${type === 'video' ? 'video-item' : ''}">
2192
+ ${storage}
2193
+ <div class="lazy-placeholder" style="padding: 2rem; text-align: center; background: var(--light-gray-color); display: flex; flex-direction: column; align-items: center; justify-content: center; height: 180px;">
2194
+ <button class="ai-fix-btn" onclick="loadMedia('${uniqueId}', '${type}')" style="margin: 0 auto; font-size: 0.9rem;">${btnText}</button>
2195
+ <span style="font-size: 0.8rem; color: var(--text-color-secondary); margin-top: 8px;">(Click to view)</span>
2196
+ </div>
2197
+ ${mediaTag}
2198
+ <div class="attachment-info">
2199
+ <div class="trace-actions">
2200
+ <a href="#" onclick="event.preventDefault(); downloadMedia('${uniqueId}', '${filename}')" class="download-trace" style="width:100%; text-align:center;">Download</a>
2201
+ </div>
2202
+ </div>
2203
+ </div>`;
2204
+ }
1784
2205
  /**
1785
2206
  * Generates the HTML report.
1786
2207
  * @param {object} reportData - The data for the report.
@@ -1813,16 +2234,46 @@ function generateHTML(reportData, trendData = null) {
1813
2234
  * Generates the HTML for the test cases.
1814
2235
  * @returns {string} The HTML for the test cases.
1815
2236
  */
1816
- function generateTestCasesHTML(subset = results, baseIndex = 0) {
1817
- if (!results || results.length === 0)
2237
+ // MODIFIED: Accepts 'subset' (chunk of tests) and 'offset' (start index)
2238
+ function generateTestCasesHTML(subset, offset = 0) {
2239
+ // Use the subset if provided, otherwise fallback to all results (legacy compatibility)
2240
+ const data = subset || results;
2241
+
2242
+ if (!data || data.length === 0)
1818
2243
  return '<div class="no-tests">No test results found in this run.</div>';
1819
- return subset
2244
+
2245
+ return data
1820
2246
  .map((test, i) => {
1821
- const testIndex = baseIndex + i;
2247
+ // Calculate the global index (essential for unique IDs across chunks)
2248
+ const testIndex = offset + i;
2249
+
1822
2250
  const browser = test.browser || "unknown";
1823
2251
  const testFileParts = test.name.split(" > ");
1824
2252
  const testTitle =
1825
2253
  testFileParts[testFileParts.length - 1] || "Unnamed Test";
2254
+
2255
+ // --- Severity Logic ---
2256
+ const severity = test.severity || "Medium";
2257
+ const getSeverityColor = (level) => {
2258
+ switch (level) {
2259
+ case "Minor":
2260
+ return "#006064";
2261
+ case "Low":
2262
+ return "#FFA07A";
2263
+ case "Medium":
2264
+ return "#577A11";
2265
+ case "High":
2266
+ return "#B71C1C";
2267
+ case "Critical":
2268
+ return "#64158A";
2269
+ default:
2270
+ return "#577A11";
2271
+ }
2272
+ };
2273
+ const severityColor = getSeverityColor(severity);
2274
+ const severityBadge = `<span class="status-badge" style="background-color: ${severityColor}; margin-right: 8px;">${severity}</span>`;
2275
+
2276
+ // --- Step Generation ---
1826
2277
  const generateStepsHTML = (steps, depth = 0) => {
1827
2278
  if (!steps || steps.length === 0)
1828
2279
  return "<div class='no-steps'>No steps recorded for this test.</div>";
@@ -1834,385 +2285,267 @@ function generateHTML(reportData, trendData = null) {
1834
2285
  ? `step-hook step-hook-${step.hookType}`
1835
2286
  : "";
1836
2287
  const hookIndicator = isHook ? ` (${step.hookType} hook)` : "";
1837
- return `<div class="step-item" style="--depth: ${depth};"><div class="step-header ${stepClass}" role="button" aria-expanded="false"><span class="step-icon">${getStatusIcon(
1838
- step.status
1839
- )}</span><span class="step-title">${sanitizeHTML(
2288
+ return `
2289
+ <div class="step-item" style="--depth: ${depth};">
2290
+ <div class="step-header ${stepClass}" role="button" aria-expanded="false">
2291
+ <span class="step-icon">${getStatusIcon(step.status)}</span>
2292
+ <span class="step-title">${sanitizeHTML(
1840
2293
  step.title
1841
- )}${hookIndicator}</span><span class="step-duration">${formatDuration(
2294
+ )}${hookIndicator}</span>
2295
+ <span class="step-duration">${formatDuration(
1842
2296
  step.duration
1843
- )}</span></div><div class="step-details" style="display: none;">${
2297
+ )}</span>
2298
+ </div>
2299
+ <div class="step-details" style="display: none;">
2300
+ ${
1844
2301
  step.codeLocation
1845
2302
  ? `<div class="step-info code-section"><strong>Location:</strong> ${sanitizeHTML(
1846
2303
  step.codeLocation
1847
2304
  )}</div>`
1848
2305
  : ""
1849
- }${
2306
+ }
2307
+ ${
1850
2308
  step.errorMessage
1851
- ? `<div class="test-error-summary">${
1852
- step.stackTrace
1853
- ? `<div class="stack-trace">${formatPlaywrightError(
1854
- step.stackTrace
1855
- )}</div>`
1856
- : ""
1857
- }<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
1858
- : ""
1859
- }${(() => {
1860
- if (!step.attachments || step.attachments.length === 0)
1861
- return "";
1862
- return `<div class="attachments-section"><h4>Step Attachments</h4><div class="attachments-grid">${step.attachments
1863
- .map((attachment) => {
1864
- try {
1865
- const attachmentPath = path.resolve(
1866
- DEFAULT_OUTPUT_DIR,
1867
- attachment.path
1868
- );
1869
- if (!fsExistsSync(attachmentPath)) {
1870
- return `<div class="attachment-item error">Attachment not found: ${sanitizeHTML(
1871
- attachment.name
1872
- )}</div>`;
2309
+ ? `<div class="test-error-summary">
2310
+ ${
2311
+ step.stackTrace
2312
+ ? `<div class="stack-trace">${formatPlaywrightError(
2313
+ step.stackTrace
2314
+ )}</div>`
2315
+ : ""
1873
2316
  }
1874
- const attachmentBase64 =
1875
- readFileSync(attachmentPath).toString("base64");
1876
- const attachmentDataUri = `data:${attachment.contentType};base64,${attachmentBase64}`;
1877
- return `<div class="attachment-item generic-attachment">
1878
- <div class="attachment-icon">${getAttachmentIcon(
1879
- attachment.contentType
1880
- )}</div>
1881
- <div class="attachment-caption">
1882
- <span class="attachment-name" title="${sanitizeHTML(
1883
- attachment.name
1884
- )}">${sanitizeHTML(attachment.name)}</span>
1885
- <span class="attachment-type">${sanitizeHTML(
1886
- attachment.contentType
1887
- )}</span>
1888
- </div>
1889
- <div class="attachment-info">
1890
- <div class="trace-actions">
1891
- <a href="#" data-href="${attachmentDataUri}" class="view-full lazy-load-attachment" target="_blank">View</a>
1892
- <a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(
1893
- attachment.name
1894
- )}">Download</a>
1895
- </div>
1896
- </div>
1897
- </div>`;
1898
- } catch (e) {
1899
- return `<div class="attachment-item error">Failed to load attachment: ${sanitizeHTML(
1900
- attachment.name
1901
- )}</div>`;
1902
- }
1903
- })
1904
- .join("")}</div></div>`;
1905
- })()}${
2317
+ <button class="copy-error-btn" onclick="copyErrorToClipboard(this)" style="margin-top: 8px; padding: 4px 8px; background: #f0f0f0; border: 2px solid #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; border-color: #8B0000; color: #8B0000;" onmouseover="this.style.background='#e0e0e0'" onmouseout="this.style.background='#f0f0f0'">Copy Error Prompt</button>
2318
+ </div>`
2319
+ : ""
2320
+ }
2321
+ ${
1906
2322
  hasNestedSteps
1907
2323
  ? `<div class="nested-steps">${generateStepsHTML(
1908
2324
  step.steps,
1909
2325
  depth + 1
1910
2326
  )}</div>`
1911
2327
  : ""
1912
- }</div></div>`;
2328
+ }
2329
+ </div>
2330
+ </div>`;
1913
2331
  })
1914
2332
  .join("");
1915
2333
  };
1916
- return `<div class="test-case" data-status="${
1917
- test.status
1918
- }" data-browser="${sanitizeHTML(browser)}" data-tags="${(
1919
- test.tags || []
1920
- )
2334
+
2335
+ return `
2336
+ <div class="test-case" data-status="${
2337
+ test.status
2338
+ }" data-browser="${sanitizeHTML(browser)}" data-tags="${(test.tags || [])
1921
2339
  .join(",")
1922
- .toLowerCase()}" data-test-id="${sanitizeHTML(
1923
- String(test.id || testIndex)
1924
- )}">
1925
- <div class="test-case-header" role="button" aria-expanded="false"><div class="test-case-summary"><span class="status-badge ${getStatusClass(
1926
- test.status
1927
- )}">${String(
2340
+ .toLowerCase()}">
2341
+ <div class="test-case-header" role="button" aria-expanded="false">
2342
+ <div class="test-case-summary">
2343
+ <span class="status-badge ${getStatusClass(test.status)}">${String(
1928
2344
  test.status
1929
- ).toUpperCase()}</span><span class="test-case-title" title="${sanitizeHTML(
1930
- test.name
1931
- )}">${sanitizeHTML(
1932
- testTitle
1933
- )}</span><span class="test-case-browser">(${sanitizeHTML(
1934
- browser
1935
- )})</span></div><div class="test-case-meta">${
1936
- test.tags && test.tags.length > 0
1937
- ? test.tags
1938
- .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
1939
- .join(" ")
1940
- : ""
1941
- }<span class="test-duration">${formatDuration(
1942
- test.duration
1943
- )}</span></div></div>
1944
- <div class="test-case-content" style="display: none;">
1945
- <p><strong>Full Path:</strong> ${sanitizeHTML(
1946
- test.name
1947
- )}</p>
1948
- ${
1949
- test.annotations && test.annotations.length > 0
1950
- ? `<div class="annotations-section" style="margin: 12px 0; padding: 12px; background-color: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.3); border-left: 4px solid #8b5cf6; border-radius: 4px;">
1951
- <h4 style="margin-top: 0; margin-bottom: 10px; color: #8b5cf6; font-size: 1.1em;">📌 Annotations</h4>
1952
- ${test.annotations
1953
- .map((annotation, idx) => {
1954
- const isIssueOrBug =
1955
- annotation.type === "issue" ||
1956
- annotation.type === "bug";
1957
- const descriptionText =
1958
- annotation.description || "";
1959
- const typeLabel = sanitizeHTML(
1960
- annotation.type
1961
- );
1962
- const descriptionHtml =
1963
- isIssueOrBug &&
1964
- descriptionText.match(/^[A-Z]+-\d+$/)
1965
- ? `<a href="#" class="annotation-link" data-annotation="${sanitizeHTML(
1966
- descriptionText
1967
- )}" style="color: #3b82f6; text-decoration: underline; cursor: pointer;">${sanitizeHTML(
1968
- descriptionText
1969
- )}</a>`
1970
- : sanitizeHTML(descriptionText);
1971
- const locationText = annotation.location
1972
- ? `<div style="font-size: 0.85em; color: #6b7280; margin-top: 4px;">Location: ${sanitizeHTML(
1973
- annotation.location.file
1974
- )}:${annotation.location.line}:${
1975
- annotation.location.column
1976
- }</div>`
1977
- : "";
1978
- return `<div style="margin-bottom: ${
1979
- idx < test.annotations.length - 1
1980
- ? "10px"
1981
- : "0"
1982
- };">
1983
- <strong style="color: #8b5cf6;">Type:</strong> <span style="background-color: rgba(139, 92, 246, 0.2); padding: 2px 8px; border-radius: 4px; font-size: 0.9em;">${typeLabel}</span>
1984
- ${
1985
- descriptionText
1986
- ? `<br><strong style="color: #8b5cf6;">Description:</strong> ${descriptionHtml}`
1987
- : ""
1988
- }
1989
- ${locationText}
1990
- </div>`;
1991
- })
1992
- .join("")}
1993
- </div>`
1994
- : ""
1995
- }
1996
- <p><strong>Test run Worker ID:</strong> ${sanitizeHTML(
1997
- test.workerId
1998
- )} [<strong>Total No. of Workers:</strong> ${sanitizeHTML(
2345
+ ).toUpperCase()}</span>
2346
+ <span class="test-case-title" title="${sanitizeHTML(
2347
+ test.name
2348
+ )}">${sanitizeHTML(testTitle)}</span>
2349
+ <span class="test-case-browser">(${sanitizeHTML(browser)})</span>
2350
+ </div>
2351
+ <div class="test-case-meta">
2352
+ ${severityBadge}
2353
+ ${
2354
+ test.tags && test.tags.length > 0
2355
+ ? test.tags
2356
+ .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
2357
+ .join(" ")
2358
+ : ""
2359
+ }
2360
+ <span class="test-duration">${formatDuration(test.duration)}</span>
2361
+ </div>
2362
+ </div>
2363
+ <div class="test-case-content" style="display: none;">
2364
+ <p><strong>Full Path:</strong> ${sanitizeHTML(test.name)}</p>
2365
+ ${
2366
+ test.annotations && test.annotations.length > 0
2367
+ ? `<div class="annotations-section" style="margin: 12px 0; padding: 12px; background-color: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.3); border-left: 4px solid #8b5cf6; border-radius: 4px;">
2368
+ <h4 style="margin-top: 0; margin-bottom: 10px; color: #8b5cf6; font-size: 1.1em;">📌 Annotations</h4>
2369
+ ${test.annotations
2370
+ .map((annotation, idx) => {
2371
+ const isIssueOrBug =
2372
+ annotation.type === "issue" ||
2373
+ annotation.type === "bug";
2374
+ const descriptionText = annotation.description || "";
2375
+ const typeLabel = sanitizeHTML(annotation.type);
2376
+ const descriptionHtml =
2377
+ isIssueOrBug && descriptionText.match(/^[A-Z]+-\d+$/)
2378
+ ? `<a href="#" class="annotation-link" data-annotation="${sanitizeHTML(
2379
+ descriptionText
2380
+ )}" style="color: #3b82f6; text-decoration: underline; cursor: pointer;">${sanitizeHTML(
2381
+ descriptionText
2382
+ )}</a>`
2383
+ : sanitizeHTML(descriptionText);
2384
+ const locationText = annotation.location
2385
+ ? `<div style="font-size: 0.85em; color: #6b7280; margin-top: 4px;">Location: ${sanitizeHTML(
2386
+ annotation.location.file
2387
+ )}:${annotation.location.line}:${
2388
+ annotation.location.column
2389
+ }</div>`
2390
+ : "";
2391
+ return `<div style="margin-bottom: ${
2392
+ idx < test.annotations.length - 1 ? "10px" : "0"
2393
+ };"><strong style="color: #8b5cf6;">Type:</strong> <span style="background-color: rgba(139, 92, 246, 0.2); padding: 2px 8px; border-radius: 4px; font-size: 0.9em;">${typeLabel}</span>${
2394
+ descriptionText
2395
+ ? `<br><strong style="color: #8b5cf6;">Description:</strong> ${descriptionHtml}`
2396
+ : ""
2397
+ }${locationText}</div>`;
2398
+ })
2399
+ .join("")}
2400
+ </div>`
2401
+ : ""
2402
+ }
2403
+ <p><strong>Test run Worker ID:</strong> ${sanitizeHTML(
2404
+ test.workerId
2405
+ )} [<strong>Total No. of Workers:</strong> ${sanitizeHTML(
1999
2406
  test.totalWorkers
2000
2407
  )}]</p>
2001
- ${
2002
- test.errorMessage
2003
- ? `<div class="test-error-summary">${formatPlaywrightError(
2004
- test.errorMessage
2005
- )}<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
2006
- : ""
2007
- }
2008
- ${
2009
- test.snippet
2010
- ? `<div class="code-section"><h4>Error Snippet</h4><pre><code>${formatPlaywrightError(
2011
- test.snippet
2012
- )}</code></pre></div>`
2013
- : ""
2014
- }
2015
- <h4>Steps</h4><div class="steps-list">${generateStepsHTML(
2016
- test.steps
2017
- )}</div>
2018
- ${(() => {
2019
- if (!test.stdout || test.stdout.length === 0)
2020
- return "";
2021
- // Create a unique ID for the <pre> element to target it for copying
2022
- const logId = `stdout-log-${test.id || testIndex}`;
2023
- return `<div class="console-output-section">
2024
- <h4>Console Output (stdout)
2025
- <button class="copy-btn" onclick="copyLogContent('${logId}', this)">Copy Console</button>
2026
- </h4>
2027
- <div class="log-wrapper">
2028
- <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(
2029
- test.stdout
2030
- .map((line) => sanitizeHTML(line))
2031
- .join("\n")
2032
- )}</pre>
2033
- </div>
2034
- </div>`;
2035
- })()}
2036
- ${
2037
- test.stderr && test.stderr.length > 0
2038
- ? (() => {
2039
- const logId = `stderr-log-${
2040
- test.id || testIndex
2041
- }`;
2042
- return `<div class="console-output-section"><h4>Console Output (stderr)</h4><pre id="${logId}" class="console-log stderr-log">${test.stderr
2043
- .map((line) => sanitizeHTML(line))
2044
- .join("\\n")}</pre></div>`;
2045
- })()
2046
- : ""
2047
- }
2048
-
2049
- ${(() => {
2050
- if (
2051
- !test.screenshots ||
2052
- test.screenshots.length === 0
2053
- )
2054
- return "";
2055
- return `<div class="attachments-section"><h4>Screenshots</h4><div class="attachments-grid">${test.screenshots
2056
- .map((screenshotPath, index) => {
2057
- try {
2058
- const imagePath = path.resolve(
2059
- DEFAULT_OUTPUT_DIR,
2060
- screenshotPath
2061
- );
2062
- if (!fsExistsSync(imagePath))
2063
- return `<div class="attachment-item error">Screenshot not found: ${sanitizeHTML(
2064
- screenshotPath
2065
- )}</div>`;
2066
- const base64ImageData =
2067
- readFileSync(imagePath).toString("base64");
2068
- return `<div class="attachment-item"><img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" data-src="data:image/png;base64,${base64ImageData}" alt="Screenshot ${
2069
- index + 1
2070
- }" 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>`;
2071
- } catch (e) {
2072
- return `<div class="attachment-item error">Failed to load screenshot: ${sanitizeHTML(
2073
- screenshotPath
2074
- )}</div>`;
2075
- }
2076
- })
2077
- .join("")}</div></div>`;
2078
- })()}
2079
-
2080
- ${(() => {
2081
- if (!test.videoPath || test.videoPath.length === 0)
2082
- return "";
2083
- return `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${test.videoPath
2084
- .map((videoPath, index) => {
2085
- try {
2086
- const videoFilePath = path.resolve(
2087
- DEFAULT_OUTPUT_DIR,
2088
- videoPath
2089
- );
2090
- if (!fsExistsSync(videoFilePath))
2091
- return `<div class="attachment-item error">Video not found: ${sanitizeHTML(
2092
- videoPath
2093
- )}</div>`;
2094
- const videoBase64 =
2095
- readFileSync(videoFilePath).toString(
2096
- "base64"
2097
- );
2098
- const fileExtension = path
2099
- .extname(videoPath)
2100
- .slice(1)
2101
- .toLowerCase();
2102
- const mimeType =
2103
- {
2104
- mp4: "video/mp4",
2105
- webm: "video/webm",
2106
- ogg: "video/ogg",
2107
- mov: "video/quicktime",
2108
- avi: "video/x-msvideo",
2109
- }[fileExtension] || "video/mp4";
2110
- const videoDataUri = `data:${mimeType};base64,${videoBase64}`;
2111
- 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>`;
2112
- } catch (e) {
2113
- return `<div class="attachment-item error">Failed to load video: ${sanitizeHTML(
2114
- videoPath
2115
- )}</div>`;
2116
- }
2117
- })
2118
- .join("")}</div></div>`;
2119
- })()}
2120
-
2121
- ${(() => {
2122
- if (!test.tracePath) return "";
2123
- try {
2124
- const traceFilePath = path.resolve(
2125
- DEFAULT_OUTPUT_DIR,
2126
- test.tracePath
2127
- );
2128
- if (!fsExistsSync(traceFilePath))
2129
- return `<div class="attachments-section"><h4>Trace File</h4><div class="attachment-item error">Trace file not found: ${sanitizeHTML(
2130
- test.tracePath
2131
- )}</div></div>`;
2132
- const traceBase64 =
2133
- readFileSync(traceFilePath).toString("base64");
2134
- const traceDataUri = `data:application/zip;base64,${traceBase64}`;
2135
- 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>`;
2136
- } catch (e) {
2137
- return `<div class="attachments-section"><h4>Trace File</h4><div class="attachment-item error">Failed to load trace file.</div></div>`;
2138
- }
2139
- })()}
2140
-
2141
- ${(() => {
2142
- if (
2143
- !test.attachments ||
2144
- test.attachments.length === 0
2145
- )
2146
- return "";
2147
-
2148
- return `<div class="attachments-section"><h4>Other Attachments</h4><div class="attachments-grid">${test.attachments
2149
- .map((attachment) => {
2150
- try {
2151
- const attachmentPath = path.resolve(
2152
- DEFAULT_OUTPUT_DIR,
2153
- attachment.path
2154
- );
2155
-
2156
- if (!fsExistsSync(attachmentPath)) {
2157
- console.warn(
2158
- `Attachment not found at: ${attachmentPath}`
2159
- );
2160
- return `<div class="attachment-item error">Attachment not found: ${sanitizeHTML(
2161
- attachment.name
2162
- )}</div>`;
2163
- }
2408
+ ${
2409
+ test.errorMessage
2410
+ ? `<div class="test-error-summary">${formatPlaywrightError(
2411
+ test.errorMessage
2412
+ )}<button class="copy-error-btn" onclick="copyErrorToClipboard(this)" style="margin-top: 8px; padding: 4px 8px; background: #f0f0f0; border: 2px solid #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; border-color: #8B0000; color: #8B0000;" onmouseover="this.style.background='#e0e0e0'" onmouseout="this.style.background='#f0f0f0'">Copy Error Prompt</button></div>`
2413
+ : ""
2414
+ }
2415
+ ${
2416
+ test.snippet
2417
+ ? `<div class="code-section"><h4>Error Snippet</h4><pre><code>${formatPlaywrightError(
2418
+ test.snippet
2419
+ )}</code></pre></div>`
2420
+ : ""
2421
+ }
2422
+ <h4>Steps</h4>
2423
+ <div class="steps-list">${generateStepsHTML(test.steps)}</div>
2424
+
2425
+ ${(() => {
2426
+ if (!test.stdout || test.stdout.length === 0) return "";
2427
+ // FIXED: Now using 'testIndex' which is guaranteed to be defined
2428
+ const logId = `stdout-log-${test.id || testIndex}`;
2429
+ return `<div class="console-output-section"><h4>Console Output (stdout) <button class="copy-btn" onclick="copyLogContent('${logId}', this)">Copy Console</button></h4><div class="log-wrapper"><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(
2430
+ test.stdout.map((line) => sanitizeHTML(line)).join("\n")
2431
+ )}</pre></div></div>`;
2432
+ })()}
2433
+
2434
+ ${(() => {
2435
+ if (!test.stderr || test.stderr.length === 0) return "";
2436
+ // FIXED: Using 'testIndex'
2437
+ const logId = `stderr-log-${test.id || testIndex}`;
2438
+ return `<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(
2439
+ test.stderr.map((line) => sanitizeHTML(line)).join("\n")
2440
+ )}</pre></div>`;
2441
+ })()}
2442
+
2443
+ ${(() => {
2444
+ if (!test.screenshots || test.screenshots.length === 0) return "";
2445
+ const screenshotsHTML = test.screenshots
2446
+ .map((screenshotPath, sIndex) => {
2447
+ try {
2448
+ const imagePath = path.resolve(
2449
+ DEFAULT_OUTPUT_DIR,
2450
+ screenshotPath
2451
+ );
2452
+ if (!fsExistsSync(imagePath))
2453
+ return `<div class="attachment-item error">Screenshot not found</div>`;
2454
+ const base64ImageData =
2455
+ readFileSync(imagePath).toString("base64");
2456
+ // LAZY LOAD: Using helper with unique ID
2457
+ return createLazyMedia(
2458
+ base64ImageData,
2459
+ "image/png",
2460
+ "image",
2461
+ sIndex + 1,
2462
+ `screenshot-${testIndex}-${sIndex}.png`
2463
+ );
2464
+ } catch (e) {
2465
+ return `<div class="attachment-item error">Error loading screenshot</div>`;
2466
+ }
2467
+ })
2468
+ .join("");
2469
+ return `<div class="attachments-section"><h4>Screenshots</h4><div class="attachments-grid">${screenshotsHTML}</div></div>`;
2470
+ })()}
2471
+
2472
+ ${(() => {
2473
+ if (!test.videoPath || test.videoPath.length === 0) return "";
2474
+ const videosHTML = test.videoPath
2475
+ .map((videoPath, vIndex) => {
2476
+ try {
2477
+ const videoFilePath = path.resolve(
2478
+ DEFAULT_OUTPUT_DIR,
2479
+ videoPath
2480
+ );
2481
+ if (!fsExistsSync(videoFilePath))
2482
+ return `<div class="attachment-item error">Video not found</div>`;
2483
+ const videoBase64 =
2484
+ readFileSync(videoFilePath).toString("base64");
2485
+ const ext = path.extname(videoPath).slice(1).toLowerCase();
2486
+ const mime =
2487
+ { mp4: "video/mp4", webm: "video/webm" }[ext] ||
2488
+ "video/mp4";
2489
+ // LAZY LOAD: Using helper with unique ID
2490
+ return createLazyMedia(
2491
+ videoBase64,
2492
+ mime,
2493
+ "video",
2494
+ vIndex + 1,
2495
+ `video-${testIndex}-${vIndex}.${ext}`
2496
+ );
2497
+ } catch (e) {
2498
+ return `<div class="attachment-item error">Error loading video</div>`;
2499
+ }
2500
+ })
2501
+ .join("");
2502
+ return `<div class="attachments-section"><h4>Videos</h4><div class="attachments-grid">${videosHTML}</div></div>`;
2503
+ })()}
2164
2504
 
2165
- const attachmentBase64 =
2166
- readFileSync(attachmentPath).toString(
2167
- "base64"
2168
- );
2169
- const attachmentDataUri = `data:${attachment.contentType};base64,${attachmentBase64}`;
2170
-
2171
- return `<div class="attachment-item generic-attachment">
2172
- <div class="attachment-icon">${getAttachmentIcon(
2173
- attachment.contentType
2174
- )}</div>
2175
- <div class="attachment-caption">
2176
- <span class="attachment-name" title="${sanitizeHTML(
2177
- attachment.name
2178
- )}">${sanitizeHTML(
2179
- attachment.name
2180
- )}</span>
2181
- <span class="attachment-type">${sanitizeHTML(
2182
- attachment.contentType
2183
- )}</span>
2184
- </div>
2185
- <div class="attachment-info">
2186
- <div class="trace-actions">
2187
- <a href="#" data-href="${attachmentDataUri}" class="view-full lazy-load-attachment" target="_blank">View</a>
2188
- <a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(
2189
- attachment.name
2190
- )}">Download</a>
2191
- </div>
2192
- </div>
2193
- </div>`;
2194
- } catch (e) {
2195
- console.error(
2196
- `Failed to process attachment "${attachment.name}":`,
2197
- e
2198
- );
2199
- return `<div class="attachment-item error">Failed to load attachment: ${sanitizeHTML(
2200
- attachment.name
2201
- )}</div>`;
2202
- }
2203
- })
2204
- .join("")}</div></div>`;
2205
- })()}
2206
-
2207
- ${
2208
- test.codeSnippet
2209
- ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${sanitizeHTML(
2210
- test.codeSnippet
2211
- )}</code></pre></div>`
2212
- : ""
2213
- }
2214
- </div>
2215
- </div>`;
2505
+ ${
2506
+ test.tracePath
2507
+ ? `<div class="attachments-section"><h4>Trace Files</h4><div class="attachments-grid"><div class="attachment-item trace-item"><div class="trace-preview"><span class="trace-icon">📄</span><span class="trace-name">${sanitizeHTML(
2508
+ path.basename(test.tracePath)
2509
+ )}</span></div><div class="attachment-info"><div class="trace-actions"><a href="${sanitizeHTML(
2510
+ test.tracePath
2511
+ )}" target="_blank" download="${sanitizeHTML(
2512
+ path.basename(test.tracePath)
2513
+ )}" class="download-trace">Download Trace</a></div></div></div></div></div>`
2514
+ : ""
2515
+ }
2516
+ ${
2517
+ test.attachments && test.attachments.length > 0
2518
+ ? `<div class="attachments-section"><h4>Other Attachments</h4><div class="attachments-grid">${test.attachments
2519
+ .map(
2520
+ (attachment) =>
2521
+ `<div class="attachment-item generic-attachment"><div class="attachment-icon">${getAttachmentIcon(
2522
+ attachment.contentType
2523
+ )}</div><div class="attachment-caption"><span class="attachment-name" title="${sanitizeHTML(
2524
+ attachment.name
2525
+ )}">${sanitizeHTML(
2526
+ attachment.name
2527
+ )}</span><span class="attachment-type">${sanitizeHTML(
2528
+ attachment.contentType
2529
+ )}</span></div><div class="attachment-info"><div class="trace-actions"><a href="${sanitizeHTML(
2530
+ attachment.path
2531
+ )}" target="_blank" class="view-full">View</a><a href="${sanitizeHTML(
2532
+ attachment.path
2533
+ )}" target="_blank" download="${sanitizeHTML(
2534
+ attachment.name
2535
+ )}" class="download-trace">Download</a></div></div></div>`
2536
+ )
2537
+ .join("")}</div></div>`
2538
+ : ""
2539
+ }
2540
+ ${
2541
+ test.codeSnippet
2542
+ ? `<div class="code-section"><h4>Code Snippet</h4><pre><code>${formatPlaywrightError(
2543
+ sanitizeHTML(test.codeSnippet)
2544
+ )}</code></pre></div>`
2545
+ : ""
2546
+ }
2547
+ </div>
2548
+ </div>`;
2216
2549
  })
2217
2550
  .join("");
2218
2551
  }
@@ -2222,10 +2555,10 @@ function generateHTML(reportData, trendData = null) {
2222
2555
  <head>
2223
2556
  <meta charset="UTF-8">
2224
2557
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2225
- <link rel="icon" type="image/png" href="https://i.postimg.cc/v817w4sg/logo.png">
2226
- <link rel="apple-touch-icon" href="https://i.postimg.cc/v817w4sg/logo.png">
2558
+ <link rel="icon" type="image/png" href="https://ocpaxmghzmfbuhxzxzae.supabase.co/storage/v1/object/public/images/pulse-report/playwright_pulse_icon.png">
2559
+ <link rel="apple-touch-icon" href="https://ocpaxmghzmfbuhxzxzae.supabase.co/storage/v1/object/public/images/pulse-report/playwright_pulse_icon.png">
2227
2560
  <script src="https://code.highcharts.com/highcharts.js" defer></script>
2228
- <title>Playwright Pulse Report (Static Report)</title>
2561
+ <title>Pulse Static Report</title>
2229
2562
 
2230
2563
  <style>
2231
2564
  :root {
@@ -2267,7 +2600,7 @@ body { font-family: var(--font-family); margin: 0; background-color: var(--backg
2267
2600
  .status-passed .value, .stat-passed svg { color: var(--success-color); }
2268
2601
  .status-failed .value, .stat-failed svg { color: var(--danger-color); }
2269
2602
  .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
2270
- .dashboard-bottom-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 28px; align-items: stretch; }
2603
+ .dashboard-bottom-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 28px; align-items: start; }
2271
2604
  .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; }
2272
2605
  .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); }
2273
2606
  .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { flex-grow: 1; min-height: 250px; }
@@ -2389,6 +2722,7 @@ aspect-ratio: 16 / 9;
2389
2722
  .status-badge-small.status-failed { background-color: var(--danger-color); }
2390
2723
  .status-badge-small.status-skipped { background-color: var(--warning-color); }
2391
2724
  .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
2725
+ .badge-severity { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; color: white; text-transform: uppercase; margin-right: 8px; vertical-align: middle; }
2392
2726
  .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); }
2393
2727
  .no-data-chart {font-size: 0.95em; padding: 18px;}
2394
2728
  .ai-failure-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 22px; }
@@ -2470,8 +2804,8 @@ aspect-ratio: 16 / 9;
2470
2804
  <div class="container">
2471
2805
  <header class="header">
2472
2806
  <div class="header-title">
2473
- <img id="report-logo" src="https://i.postimg.cc/v817w4sg/logo.png" alt="Report Logo">
2474
- <h1>Playwright Pulse Report</h1>
2807
+ <img id="report-logo" src="https://ocpaxmghzmfbuhxzxzae.supabase.co/storage/v1/object/public/images/pulse-report/playwright_pulse_icon.png" alt="Report Logo">
2808
+ <h1>Pulse Static Report</h1>
2475
2809
  </div>
2476
2810
  <div class="run-info"><strong>Run Date:</strong> ${formatDate(
2477
2811
  runSummary.timestamp
@@ -2522,7 +2856,10 @@ aspect-ratio: 16 / 9;
2522
2856
  : '<div class="no-data">Environment data not available.</div>'
2523
2857
  }
2524
2858
  </div>
2525
- ${generateSuitesWidget(suitesData)}
2859
+ <div style="display: flex; flex-direction: column; gap: 28px;">
2860
+ ${generateSuitesWidget(suitesData)}
2861
+ ${generateSeverityDistributionChart(results)}
2862
+ </div>
2526
2863
  </div>
2527
2864
  </div>
2528
2865
  <div id="test-runs" class="tab-content">
@@ -2574,6 +2911,16 @@ aspect-ratio: 16 / 9;
2574
2911
  }
2575
2912
  </div>
2576
2913
  </div>
2914
+ <div class="trend-charts-row">
2915
+ <div class="trend-chart">
2916
+ <h3 class="chart-title-header">Duration by Spec files</h3>
2917
+ ${generateSpecDurationChart(results)}
2918
+ </div>
2919
+ <div class="trend-chart">
2920
+ <h3 class="chart-title-header">Duration by Test Describe</h3>
2921
+ ${generateDescribeDurationChart(results)}
2922
+ </div>
2923
+ </div>
2577
2924
  <h2 class="tab-main-title">Test Distribution by Worker ${infoTooltip}</h2>
2578
2925
  <div class="trend-charts-row">
2579
2926
  <div class="trend-chart">
@@ -2819,6 +3166,41 @@ Code Snippet:
2819
3166
  button.classList.remove('expanded');
2820
3167
  }
2821
3168
  }
3169
+
3170
+ // --- LAZY MEDIA HANDLERS ---
3171
+ window.loadMedia = function(id, type) {
3172
+ const storage = document.getElementById('data-' + id);
3173
+ const element = document.getElementById(id);
3174
+ const placeholder = element.previousElementSibling;
3175
+
3176
+ if (storage && element) {
3177
+ const data = storage.textContent;
3178
+ element.src = data;
3179
+ element.style.display = 'block';
3180
+ if (placeholder) placeholder.style.display = 'none';
3181
+
3182
+ if (type === 'video') {
3183
+ element.play().catch(e => console.log('Autoplay prevented', e));
3184
+ }
3185
+ }
3186
+ };
3187
+
3188
+ window.downloadMedia = function(id, filename) {
3189
+ const storage = document.getElementById('data-' + id);
3190
+ if (storage) {
3191
+ const data = storage.textContent;
3192
+ const link = document.createElement('a');
3193
+ link.href = data;
3194
+ link.download = filename;
3195
+ document.body.appendChild(link);
3196
+ link.click();
3197
+ document.body.removeChild(link);
3198
+ } else {
3199
+ alert("Media data not found.");
3200
+ }
3201
+ };
3202
+
3203
+ // Ensure formatDuration is globally available... (existing code follows)
2822
3204
 
2823
3205
  function initializeReportInteractivity() {
2824
3206
  const tabButtons = document.querySelectorAll('.tab-button');
@@ -2923,10 +3305,25 @@ Code Snippet:
2923
3305
  filterTestHistoryCards();
2924
3306
  });
2925
3307
  // --- Expand/Collapse and Toggle Details Logic ---
3308
+ // --- Expand/Collapse and Toggle Details Logic ---
2926
3309
  function toggleElementDetails(headerElement, contentSelector) {
2927
3310
  let contentElement;
2928
3311
  if (headerElement.classList.contains('test-case-header')) {
3312
+ // Find the content sibling
2929
3313
  contentElement = headerElement.parentElement.querySelector('.test-case-content');
3314
+
3315
+ // --- ALLURE-STYLE LAZY RENDERING ---
3316
+ // If content is empty/not loaded, load it from the template script
3317
+ if (contentElement && !contentElement.getAttribute('data-loaded')) {
3318
+ const testCaseId = contentElement.id.replace('details-', '');
3319
+ const template = document.getElementById('tmpl-' + testCaseId);
3320
+ if (template) {
3321
+ contentElement.innerHTML = template.textContent; // Hydrate HTML
3322
+ contentElement.setAttribute('data-loaded', 'true');
3323
+ }
3324
+ }
3325
+ // -----------------------------------
3326
+
2930
3327
  } else if (headerElement.classList.contains('step-header')) {
2931
3328
  contentElement = headerElement.nextElementSibling;
2932
3329
  if (!contentElement || !contentElement.matches(contentSelector || '.step-details')) {
@@ -3393,4 +3790,4 @@ main().catch((err) => {
3393
3790
  );
3394
3791
  console.error(err.stack);
3395
3792
  process.exit(1);
3396
- });
3793
+ });