UNPKG

@arghajit/playwright-pulse-report

Version:

A Playwright reporter and dashboard for visualizing test results.

1,289 lines (1,185 loc) 169 kB
#!/usr/bin/env node import * as fs from "fs/promises"; import { readFileSync, existsSync as fsExistsSync } from "fs"; import path from "path"; import { fork } from "child_process"; import { fileURLToPath } from "url"; import { getOutputDir } from "./config-reader.mjs"; // Use dynamic import for chalk as it's ESM only let chalk; try { chalk = (await import("chalk")).default; } catch (e) { console.warn("Chalk could not be imported. Using plain console logs."); chalk = { green: (text) => text, red: (text) => text, yellow: (text) => text, blue: (text) => text, bold: (text) => text, gray: (text) => text, }; } // Default configuration const DEFAULT_OUTPUT_DIR = "pulse-report"; const DEFAULT_JSON_FILE = "playwright-pulse-report.json"; const DEFAULT_HTML_FILE = "playwright-pulse-report.html"; // Helper functions export function ansiToHtml(text) { if (!text) { return ""; } const codes = { 0: "color:inherit;font-weight:normal;font-style:normal;text-decoration:none;opacity:1;background-color:inherit;", 1: "font-weight:bold", 2: "opacity:0.6", 3: "font-style:italic", 4: "text-decoration:underline", 30: "color:#000", // black 31: "color:#d00", // red 32: "color:#0a0", // green 33: "color:#aa0", // yellow 34: "color:#00d", // blue 35: "color:#a0a", // magenta 36: "color:#0aa", // cyan 37: "color:#aaa", // light grey 39: "color:inherit", // default foreground color 40: "background-color:#000", // black background 41: "background-color:#d00", // red background 42: "background-color:#0a0", // green background 43: "background-color:#aa0", // yellow background 44: "background-color:#00d", // blue background 45: "background-color:#a0a", // magenta background 46: "background-color:#0aa", // cyan background 47: "background-color:#aaa", // light grey background 49: "background-color:inherit", // default background color 90: "color:#555", // dark grey 91: "color:#f55", // light red 92: "color:#5f5", // light green 93: "color:#ff5", // light yellow 94: "color:#55f", // light blue 95: "color:#f5f", // light magenta 96: "color:#5ff", // light cyan 97: "color:#fff", // white }; let currentStylesArray = []; let html = ""; let openSpan = false; const applyStyles = () => { if (openSpan) { html += "</span>"; openSpan = false; } if (currentStylesArray.length > 0) { const styleString = currentStylesArray.filter((s) => s).join(";"); if (styleString) { html += `<span style="${styleString}">`; openSpan = true; } } }; const resetAndApplyNewCodes = (newCodesStr) => { const newCodes = newCodesStr.split(";"); if (newCodes.includes("0")) { currentStylesArray = []; if (codes["0"]) currentStylesArray.push(codes["0"]); } for (const code of newCodes) { if (code === "0") continue; if (codes[code]) { if (code === "39") { currentStylesArray = currentStylesArray.filter( (s) => !s.startsWith("color:") ); currentStylesArray.push("color:inherit"); } else if (code === "49") { currentStylesArray = currentStylesArray.filter( (s) => !s.startsWith("background-color:") ); currentStylesArray.push("background-color:inherit"); } else { currentStylesArray.push(codes[code]); } } else if (code.startsWith("38;2;") || code.startsWith("48;2;")) { const parts = code.split(";"); const type = parts[0] === "38" ? "color" : "background-color"; if (parts.length === 5) { currentStylesArray = currentStylesArray.filter( (s) => !s.startsWith(type + ":") ); currentStylesArray.push( `${type}:rgb(${parts[2]},${parts[3]},${parts[4]})` ); } } } applyStyles(); }; const segments = text.split(/(\x1b\[[0-9;]*m)/g); for (const segment of segments) { if (!segment) continue; if (segment.startsWith("\x1b[") && segment.endsWith("m")) { const command = segment.slice(2, -1); resetAndApplyNewCodes(command); } else { const escapedContent = segment .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); html += escapedContent; } } if (openSpan) { html += "</span>"; } return html; } function sanitizeHTML(str) { if (str === null || str === undefined) return ""; return String(str).replace(/[&<>"']/g, (match) => { const replacements = { "&": "&", "<": "<", ">": ">", '"': '"', "'": "'", }; return replacements[match] || match; }); } function capitalize(str) { if (!str) return ""; return str[0].toUpperCase() + str.slice(1).toLowerCase(); } function formatPlaywrightError(error) { const commandOutput = ansiToHtml(error || error.message); return convertPlaywrightErrorToHTML(commandOutput); } function convertPlaywrightErrorToHTML(str) { if (!str) return ""; return str .replace(/^(\s+)/gm, (match) => match.replace(/ /g, " ").replace(/\t/g, " ") ) .replace(/<red>/g, '<span style="color: red;">') .replace(/<green>/g, '<span style="color: green;">') .replace(/<dim>/g, '<span style="opacity: 0.6;">') .replace(/<intensity>/g, '<span style="font-weight: bold;">') .replace(/<\/color>/g, "</span>") .replace(/<\/intensity>/g, "</span>") .replace(/\n/g, "<br>"); } function formatDuration(ms, options = {}) { const { precision = 1, invalidInputReturn = "N/A", defaultForNullUndefinedNegative = null, } = options; const validPrecision = Math.max(0, Math.floor(precision)); const zeroWithPrecision = (0).toFixed(validPrecision) + "s"; const resolvedNullUndefNegReturn = defaultForNullUndefinedNegative === null ? zeroWithPrecision : defaultForNullUndefinedNegative; if (ms === undefined || ms === null) { return resolvedNullUndefNegReturn; } const numMs = Number(ms); if (Number.isNaN(numMs) || !Number.isFinite(numMs)) { return invalidInputReturn; } if (numMs < 0) { return resolvedNullUndefNegReturn; } if (numMs === 0) { return zeroWithPrecision; } const MS_PER_SECOND = 1000; const SECONDS_PER_MINUTE = 60; const MINUTES_PER_HOUR = 60; const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; const totalRawSeconds = numMs / MS_PER_SECOND; if ( totalRawSeconds < SECONDS_PER_MINUTE && Math.ceil(totalRawSeconds) < SECONDS_PER_MINUTE ) { return `${totalRawSeconds.toFixed(validPrecision)}s`; } else { const totalMsRoundedUpToSecond = Math.ceil(numMs / MS_PER_SECOND) * MS_PER_SECOND; let remainingMs = totalMsRoundedUpToSecond; const h = Math.floor(remainingMs / (MS_PER_SECOND * SECONDS_PER_HOUR)); remainingMs %= MS_PER_SECOND * SECONDS_PER_HOUR; const m = Math.floor(remainingMs / (MS_PER_SECOND * SECONDS_PER_MINUTE)); remainingMs %= MS_PER_SECOND * SECONDS_PER_MINUTE; const s = Math.floor(remainingMs / MS_PER_SECOND); const parts = []; if (h > 0) { parts.push(`${h}h`); } if (h > 0 || m > 0 || numMs >= MS_PER_SECOND * SECONDS_PER_MINUTE) { parts.push(`${m}m`); } parts.push(`${s}s`); return parts.join(" "); } } function generateTestTrendsChart(trendData) { if (!trendData || !trendData.overall || trendData.overall.length === 0) { return '<div class="no-data">No overall trend data available for test counts.</div>'; } const chartId = `testTrendsChart-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; const renderFunctionName = `renderTestTrendsChart_${chartId.replace( /-/g, "_" )}`; const runs = trendData.overall; const series = [ { name: "Total", data: runs.map((r) => r.totalTests), color: "var(--primary-color)", marker: { symbol: "circle" }, }, { name: "Passed", data: runs.map((r) => r.passed), color: "var(--success-color)", marker: { symbol: "circle" }, }, { name: "Failed", data: runs.map((r) => r.failed), color: "var(--danger-color)", marker: { symbol: "circle" }, }, { name: "Skipped", data: runs.map((r) => r.skipped || 0), color: "var(--warning-color)", marker: { symbol: "circle" }, }, ]; const runsForTooltip = runs.map((r) => ({ runId: r.runId, timestamp: r.timestamp, duration: r.duration, })); const categoriesString = JSON.stringify(runs.map((run, i) => `Run ${i + 1}`)); const seriesString = JSON.stringify(series); const runsForTooltipString = JSON.stringify(runsForTooltip); return ` <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}"> <div class="no-data">Loading Test Volume Trends...</div> </div> <script> window.${renderFunctionName} = function() { const chartContainer = document.getElementById('${chartId}'); if (!chartContainer) { console.error("Chart container ${chartId} not found for lazy loading."); return; } if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') { try { chartContainer.innerHTML = ''; // Clear placeholder const chartOptions = { chart: { type: "line", height: 350, backgroundColor: "transparent" }, title: { text: null }, xAxis: { categories: ${categoriesString}, crosshair: true, labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}}, yAxis: { title: { text: "Test Count", style: { color: 'var(--text-color)'} }, min: 0, labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}}, legend: { layout: "horizontal", align: "center", verticalAlign: "bottom", itemStyle: { fontSize: "12px", color: 'var(--text-color)' }}, plotOptions: { series: { marker: { radius: 4, states: { hover: { radius: 6 }}}, states: { hover: { halo: { size: 5, opacity: 0.1 }}}}, line: { lineWidth: 2.5 }}, tooltip: { shared: true, useHTML: true, backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5' }, formatter: function () { const runsData = ${runsForTooltipString}; const pointIndex = this.points[0].point.x; const run = runsData[pointIndex]; let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' + 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br><br>'; this.points.forEach(point => { tooltip += '<span style="color:' + point.color + '">●</span> ' + point.series.name + ': <b>' + point.y + '</b><br>'; }); tooltip += '<br>Duration: ' + formatDuration(run.duration); return tooltip; } }, series: ${seriesString}, credits: { enabled: false } }; Highcharts.chart('${chartId}', chartOptions); } catch (e) { console.error("Error rendering chart ${chartId} (lazy):", e); chartContainer.innerHTML = '<div class="no-data">Error rendering test trends chart.</div>'; } } else { chartContainer.innerHTML = '<div class="no-data">Charting library not available for test trends.</div>'; } }; </script> `; } const accentColorAltRGB = "255, 152, 0"; // Assuming var(--accent-color-alt) is Orange #FF9800 function generateDurationTrendChart(trendData) { if (!trendData || !trendData.overall || trendData.overall.length === 0) { return '<div class="no-data">No overall trend data available for durations.</div>'; } const chartId = `durationTrendChart-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; const renderFunctionName = `renderDurationTrendChart_${chartId.replace( /-/g, "_" )}`; const runs = trendData.overall; const chartDataString = JSON.stringify(runs.map((run) => run.duration)); const categoriesString = JSON.stringify(runs.map((run, i) => `Run ${i + 1}`)); const runsForTooltip = runs.map((r) => ({ runId: r.runId, timestamp: r.timestamp, duration: r.duration, totalTests: r.totalTests, })); const runsForTooltipString = JSON.stringify(runsForTooltip); const seriesStringForRender = `[{ name: 'Duration', data: ${chartDataString}, color: 'var(--accent-color-alt)', type: 'area', marker: { symbol: 'circle', enabled: true, radius: 4, states: { hover: { radius: 6, lineWidthPlus: 0 } } }, fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, 'rgba(${accentColorAltRGB}, 0.4)'], [1, 'rgba(${accentColorAltRGB}, 0.05)']] }, lineWidth: 2.5 }]`; return ` <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}"> <div class="no-data">Loading Duration Trends...</div> </div> <script> window.${renderFunctionName} = function() { const chartContainer = document.getElementById('${chartId}'); if (!chartContainer) { console.error("Chart container ${chartId} not found for lazy loading."); return; } if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') { try { chartContainer.innerHTML = ''; // Clear placeholder const chartOptions = { chart: { type: 'area', height: 350, backgroundColor: 'transparent' }, title: { text: null }, xAxis: { categories: ${categoriesString}, crosshair: true, labels: { style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}}, yAxis: { title: { text: 'Duration', style: { color: 'var(--text-color)' } }, labels: { formatter: function() { return formatDuration(this.value); }, style: { color: 'var(--text-color-secondary)', fontSize: '12px' }}, min: 0 }, legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom', itemStyle: { fontSize: '12px', color: 'var(--text-color)' }}, plotOptions: { area: { lineWidth: 2.5, states: { hover: { lineWidthPlus: 0 } }, threshold: null }}, tooltip: { shared: true, useHTML: true, backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5' }, formatter: function () { const runsData = ${runsForTooltipString}; const pointIndex = this.points[0].point.x; const run = runsData[pointIndex]; let tooltip = '<strong>Run ' + (run.runId || pointIndex + 1) + '</strong><br>' + 'Date: ' + new Date(run.timestamp).toLocaleString() + '<br>'; this.points.forEach(point => { tooltip += '<span style="color:' + point.series.color + '">●</span> ' + point.series.name + ': <b>' + formatDuration(point.y) + '</b><br>'; }); tooltip += '<br>Tests: ' + run.totalTests; return tooltip; } }, series: ${seriesStringForRender}, // This is already a string representation of an array credits: { enabled: false } }; Highcharts.chart('${chartId}', chartOptions); } catch (e) { console.error("Error rendering chart ${chartId} (lazy):", e); chartContainer.innerHTML = '<div class="no-data">Error rendering duration trend chart.</div>'; } } else { chartContainer.innerHTML = '<div class="no-data">Charting library not available for duration trends.</div>'; } }; </script> `; } function formatDate(dateStrOrDate) { if (!dateStrOrDate) return "N/A"; try { const date = new Date(dateStrOrDate); if (isNaN(date.getTime())) return "Invalid Date"; return ( date.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit", }) + " " + date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) ); } catch (e) { return "Invalid Date Format"; } } function generateTestHistoryChart(history) { if (!history || history.length === 0) return '<div class="no-data-chart">No data for chart</div>'; const validHistory = history.filter( (h) => h && typeof h.duration === "number" && h.duration >= 0 ); if (validHistory.length === 0) return '<div class="no-data-chart">No valid data for chart</div>'; const chartId = `testHistoryChart-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; const renderFunctionName = `renderTestHistoryChart_${chartId.replace( /-/g, "_" )}`; const seriesDataPoints = validHistory.map((run) => { let color; switch (String(run.status).toLowerCase()) { case "passed": color = "var(--success-color)"; break; case "failed": color = "var(--danger-color)"; break; case "skipped": color = "var(--warning-color)"; break; default: color = "var(--dark-gray-color)"; } return { y: run.duration, marker: { fillColor: color, symbol: "circle", radius: 3.5, states: { hover: { radius: 5 } }, }, status: run.status, runId: run.runId, }; }); const accentColorRGB = "103, 58, 183"; // Assuming var(--accent-color) is Deep Purple #673ab7 const categoriesString = JSON.stringify( validHistory.map((_, i) => `R${i + 1}`) ); const seriesDataPointsString = JSON.stringify(seriesDataPoints); return ` <div id="${chartId}" style="width: 320px; height: 100px;" class="lazy-load-chart" data-render-function-name="${renderFunctionName}"> <div class="no-data-chart">Loading History...</div> </div> <script> window.${renderFunctionName} = function() { const chartContainer = document.getElementById('${chartId}'); if (!chartContainer) { console.error("Chart container ${chartId} not found for lazy loading."); return; } if (typeof Highcharts !== 'undefined' && typeof formatDuration !== 'undefined') { try { chartContainer.innerHTML = ''; // Clear placeholder const chartOptions = { chart: { type: 'area', height: 100, width: 320, backgroundColor: 'transparent', spacing: [10,10,15,35] }, title: { text: null }, xAxis: { categories: ${categoriesString}, labels: { style: { fontSize: '10px', color: 'var(--text-color-secondary)' }}}, yAxis: { title: { text: null }, labels: { formatter: function() { return formatDuration(this.value); }, style: { fontSize: '10px', color: 'var(--text-color-secondary)' }, align: 'left', x: -35, y: 3 }, min: 0, gridLineWidth: 0, tickAmount: 4 }, legend: { enabled: false }, plotOptions: { area: { lineWidth: 2, lineColor: 'var(--accent-color)', fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, stops: [[0, 'rgba(${accentColorRGB}, 0.4)'],[1, 'rgba(${accentColorRGB}, 0)']]}, marker: { enabled: true }, threshold: null } }, tooltip: { useHTML: true, backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5', padding: '8px' }, formatter: function() { const pointData = this.point; let statusBadgeHtml = '<span style="padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; background-color: '; switch(String(pointData.status).toLowerCase()) { case 'passed': statusBadgeHtml += 'var(--success-color)'; break; case 'failed': statusBadgeHtml += 'var(--danger-color)'; break; case 'skipped': statusBadgeHtml += 'var(--warning-color)'; break; default: statusBadgeHtml += 'var(--dark-gray-color)'; } statusBadgeHtml += ';">' + String(pointData.status).toUpperCase() + '</span>'; return '<strong>Run ' + (pointData.runId || (this.point.index + 1)) + '</strong><br>' + 'Status: ' + statusBadgeHtml + '<br>' + 'Duration: ' + formatDuration(pointData.y); } }, series: [{ data: ${seriesDataPointsString}, showInLegend: false }], credits: { enabled: false } }; Highcharts.chart('${chartId}', chartOptions); } catch (e) { console.error("Error rendering chart ${chartId} (lazy):", e); chartContainer.innerHTML = '<div class="no-data-chart">Error rendering history chart.</div>'; } } else { chartContainer.innerHTML = '<div class="no-data-chart">Charting library not available for history.</div>'; } }; </script> `; } function generatePieChart(data, chartWidth = 300, chartHeight = 300) { const total = data.reduce((sum, d) => sum + d.value, 0); if (total === 0) { return '<div class="pie-chart-wrapper"><h3>Test Distribution</h3><div class="no-data">No data for Test Distribution chart.</div></div>'; } const passedEntry = data.find((d) => d.label === "Passed"); const passedPercentage = Math.round( ((passedEntry ? passedEntry.value : 0) / total) * 100 ); const chartId = `pieChart-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; const seriesData = [ { name: "Tests", // Changed from 'Test Distribution' for tooltip clarity data: data .filter((d) => d.value > 0) .map((d) => { let color; switch (d.label) { case "Passed": color = "var(--success-color)"; break; case "Failed": color = "var(--danger-color)"; break; case "Skipped": color = "var(--warning-color)"; break; default: color = "#CCCCCC"; // A neutral default color } return { name: d.label, y: d.value, color: color }; }), size: "100%", innerSize: "55%", dataLabels: { enabled: false }, showInLegend: true, }, ]; // Approximate font size for center text, can be adjusted or made dynamic with more client-side JS const centerTitleFontSize = Math.max(12, Math.min(chartWidth, chartHeight) / 12) + "px"; const centerSubtitleFontSize = Math.max(10, Math.min(chartWidth, chartHeight) / 18) + "px"; const optionsObjectString = ` { chart: { type: 'pie', width: ${chartWidth}, height: ${ chartHeight - 40 }, // Adjusted height to make space for legend if chartHeight is for the whole wrapper backgroundColor: 'transparent', plotShadow: false, spacingBottom: 40 // Ensure space for legend }, title: { text: '${passedPercentage}%', align: 'center', verticalAlign: 'middle', y: 5, style: { fontSize: '${centerTitleFontSize}', fontWeight: 'bold', color: 'var(--primary-color)' } }, subtitle: { text: 'Passed', align: 'center', verticalAlign: 'middle', y: 25, style: { fontSize: '${centerSubtitleFontSize}', color: 'var(--text-color-secondary)' } }, tooltip: { pointFormat: '{series.name}: <b>{point.percentage:.1f}%</b> ({point.y})', backgroundColor: 'rgba(10,10,10,0.92)', borderColor: 'rgba(10,10,10,0.92)', style: { color: '#f5f5f5' } }, legend: { layout: 'horizontal', align: 'center', verticalAlign: 'bottom', itemStyle: { color: 'var(--text-color)', fontWeight: 'normal', fontSize: '12px' } }, plotOptions: { pie: { allowPointSelect: true, cursor: 'pointer', borderWidth: 3, borderColor: 'var(--card-background-color)', // Match D3 style states: { hover: { // Using default Highcharts halo which is generally good } } } }, series: ${JSON.stringify(seriesData)}, credits: { enabled: false } } `; return ` <div class="pie-chart-wrapper" style="align-items: center; max-height: 450px"> <div style="display: flex; align-items: start; width: 100%;"><h3>Test Distribution</h3></div> <div id="${chartId}" style="width: ${chartWidth}px; height: ${ chartHeight - 40 }px;"></div> <script> document.addEventListener('DOMContentLoaded', function() { if (typeof Highcharts !== 'undefined') { try { const chartOptions = ${optionsObjectString}; Highcharts.chart('${chartId}', chartOptions); } catch (e) { console.error("Error rendering chart ${chartId}:", e); document.getElementById('${chartId}').innerHTML = '<div class="no-data">Error rendering pie chart.</div>'; } } else { document.getElementById('${chartId}').innerHTML = '<div class="no-data">Charting library not available.</div>'; } }); </script> </div> `; } function generateEnvironmentDashboard(environment, dashboardHeight = 600) { // Format memory for display const formattedMemory = environment.memory.replace(/(\d+\.\d{2})GB/, "$1 GB"); // Generate a unique ID for the dashboard const dashboardId = `envDashboard-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; const cardHeight = Math.floor(dashboardHeight * 0.44); const cardContentPadding = 16; // px // Logic for Run Context const runContext = process.env.CI ? "CI" : "Local Test"; return ` <div class="environment-dashboard-wrapper" id="${dashboardId}"> <style> .environment-dashboard-wrapper *, .environment-dashboard-wrapper *::before, .environment-dashboard-wrapper *::after { box-sizing: border-box; } .environment-dashboard-wrapper { --primary-color: #007bff; --primary-light-color: #e6f2ff; --secondary-color: #6c757d; --success-color: #28a745; --success-light-color: #eaf6ec; --warning-color: #ffc107; --warning-light-color: #fff9e6; --danger-color: #dc3545; --background-color: #ffffff; --card-background-color: #ffffff; --text-color: #212529; --text-color-secondary: #6c757d; --border-color: #dee2e6; --border-light-color: #f1f3f5; --icon-color: #495057; --chip-background: #e9ecef; --chip-text: #495057; --shadow-color: rgba(0, 0, 0, 0.075); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; background-color: var(--background-color); border-radius: 12px; box-shadow: 0 6px 12px var(--shadow-color); padding: 24px; color: var(--text-color); display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr; gap: 20px; font-size: 14px; } /* Mobile Responsiveness */ @media (max-width: 768px) { .environment-dashboard-wrapper { grid-template-columns: 1fr; /* Stack columns on mobile */ grid-template-rows: auto; padding: 16px; height: auto !important; /* Allow height to grow */ } .env-card { height: auto !important; /* Allow cards to grow based on content */ min-height: 200px; } } .env-dashboard-header { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; margin-bottom: 8px; flex-wrap: wrap; /* Allow wrapping header items */ gap: 10px; } .env-dashboard-title { font-size: 1.5rem; font-weight: 600; color: var(--text-color); margin: 0; } .env-dashboard-subtitle { font-size: 0.875rem; color: var(--text-color-secondary); margin-top: 4px; } .env-card { background-color: var(--card-background-color); border-radius: 8px; padding: ${cardContentPadding}px; box-shadow: 0 3px 6px var(--shadow-color); height: ${cardHeight}px; display: flex; flex-direction: column; overflow: hidden; } .env-card-header { font-weight: 600; font-size: 1rem; margin-bottom: 12px; color: var(--text-color); display: flex; align-items: center; padding-bottom: 8px; border-bottom: 1px solid var(--border-light-color); } .env-card-header svg { margin-right: 10px; width: 18px; height: 18px; fill: var(--icon-color); } .env-card-content { flex-grow: 1; overflow-y: auto; padding-right: 5px; } .env-detail-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border-light-color); font-size: 0.875rem; flex-wrap: wrap; /* Allow details to wrap on very small screens */ gap: 8px; } .env-detail-row:last-child { border-bottom: none; } .env-detail-label { color: var(--text-color-secondary); font-weight: 500; margin-right: 10px; } .env-detail-value { color: var(--text-color); font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; text-align: right; word-break: break-all; margin-left: auto; /* Push to right */ } .env-chip { display: inline-block; padding: 4px 10px; border-radius: 16px; font-size: 0.75rem; font-weight: 500; line-height: 1.2; background-color: var(--chip-background); color: var(--chip-text); } .env-chip-primary { background-color: var(--primary-light-color); color: var(--primary-color); } .env-chip-success { background-color: var(--success-light-color); color: var(--success-color); } .env-chip-warning { background-color: var(--warning-light-color); color: var(--warning-color); } .env-cpu-cores { display: flex; align-items: center; gap: 6px; } .env-core-indicator { width: 12px; height: 12px; border-radius: 50%; background-color: var(--success-color); border: 1px solid rgba(0,0,0,0.1); } .env-core-indicator.inactive { background-color: var(--border-light-color); opacity: 0.7; border-color: var(--border-color); } </style> <div class="env-dashboard-header"> <div> <h3 class="env-dashboard-title">System Environment</h3> <p class="env-dashboard-subtitle">Snapshot of the execution environment</p> </div> <span class="env-chip env-chip-primary">${environment.host}</span> </div> <div class="env-card"> <div class="env-card-header"> <svg viewBox="0 0 24 24"><path d="M4 6h16V4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8h-2v10H4V6zm18-2h-4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2H6a2 2 0 0 0-2 2v2h20V6a2 2 0 0 0-2-2zM8 12h8v2H8v-2zm0 4h8v2H8v-2z"/></svg> Hardware </div> <div class="env-card-content"> <div class="env-detail-row"> <span class="env-detail-label">CPU Model</span> <span class="env-detail-value">${environment.cpu.model}</span> </div> <div class="env-detail-row"> <span class="env-detail-label">CPU Cores</span> <span class="env-detail-value"> <div class="env-cpu-cores"> ${Array.from( { length: Math.max(0, environment.cpu.cores || 0) }, (_, i) => `<div class="env-core-indicator ${ i >= (environment.cpu.cores >= 8 ? 8 : environment.cpu.cores) ? "inactive" : "" }" title="Core ${i + 1}"></div>` ).join("")} <span>${environment.cpu.cores || "N/A"} cores</span> </div> </span> </div> <div class="env-detail-row"> <span class="env-detail-label">Memory</span> <span class="env-detail-value">${formattedMemory}</span> </div> </div> </div> <div class="env-card"> <div class="env-card-header"> <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-0.01 18c-2.76 0-5.26-1.12-7.07-2.93A7.973 7.973 0 0 1 4 12c0-2.21.9-4.21 2.36-5.64A7.994 7.994 0 0 1 11.99 4c4.41 0 8 3.59 8 8 0 2.76-1.12 5.26-2.93 7.07A7.973 7.973 0 0 1 11.99 20zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/></svg> Operating System </div> <div class="env-card-content"> <div class="env-detail-row"> <span class="env-detail-label">OS Type</span> <span class="env-detail-value">${ environment.os.split(" ")[0] === "darwin" ? "darwin (macOS)" : environment.os.split(" ")[0] || "Unknown" }</span> </div> <div class="env-detail-row"> <span class="env-detail-label">OS Version</span> <span class="env-detail-value">${ environment.os.split(" ")[1] || "N/A" }</span> </div> <div class="env-detail-row"> <span class="env-detail-label">Hostname</span> <span class="env-detail-value" title="${environment.host}">${ environment.host }</span> </div> </div> </div> <div class="env-card"> <div class="env-card-header"> <svg viewBox="0 0 24 24"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg> Node.js Runtime </div> <div class="env-card-content"> <div class="env-detail-row"> <span class="env-detail-label">Node Version</span> <span class="env-detail-value">${environment.node}</span> </div> <div class="env-detail-row"> <span class="env-detail-label">V8 Engine</span> <span class="env-detail-value">${environment.v8}</span> </div> <div class="env-detail-row"> <span class="env-detail-label">Working Dir</span> <span class="env-detail-value" title="${environment.cwd}">${ environment.cwd.length > 25 ? "..." + environment.cwd.slice(-22) : environment.cwd }</span> </div> </div> </div> <div class="env-card"> <div class="env-card-header"> <svg viewBox="0 0 24 24"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h.71C7.37 8.69 9.48 7 12 7c2.76 0 5 2.24 5 5v1h2c1.66 0 3 1.34 3 3s-1.34 3-3 3z"/></svg> System Summary </div> <div class="env-card-content"> <div class="env-detail-row"> <span class="env-detail-label">Platform Arch</span> <span class="env-detail-value"> <span class="env-chip ${ environment.os.includes("darwin") && environment.cpu.model.toLowerCase().includes("apple") ? "env-chip-success" : "env-chip-warning" }"> ${ environment.os.includes("darwin") && environment.cpu.model.toLowerCase().includes("apple") ? "Apple Silicon" : environment.cpu.model.toLowerCase().includes("arm") || environment.cpu.model.toLowerCase().includes("aarch64") ? "ARM-based" : "x86/Other" } </span> </span> </div> <div class="env-detail-row"> <span class="env-detail-label">Memory per Core</span> <span class="env-detail-value">${ environment.cpu.cores > 0 ? ( parseFloat(environment.memory) / environment.cpu.cores ).toFixed(2) + " GB" : "N/A" }</span> </div> <div class="env-detail-row"> <span class="env-detail-label">Run Context</span> <span class="env-detail-value">${runContext}</span> </div> </div> </div> </div> `; } function generateWorkerDistributionChart(results) { if (!results || results.length === 0) { return '<div class="no-data">No test results data available to display worker distribution.</div>'; } // 1. Sort results by startTime to ensure chronological order const sortedResults = [...results].sort((a, b) => { const timeA = a.startTime ? new Date(a.startTime).getTime() : 0; const timeB = b.startTime ? new Date(b.startTime).getTime() : 0; return timeA - timeB; }); const workerData = sortedResults.reduce((acc, test) => { const workerId = typeof test.workerId !== "undefined" ? test.workerId : "N/A"; if (!acc[workerId]) { acc[workerId] = { passed: 0, failed: 0, skipped: 0, tests: [] }; } const status = String(test.status).toLowerCase(); if (status === "passed" || status === "failed" || status === "skipped") { acc[workerId][status]++; } const testTitleParts = test.name.split(" > "); const testTitle = testTitleParts[testTitleParts.length - 1] || "Unnamed Test"; // Store both name and status for each test acc[workerId].tests.push({ name: testTitle, status: status }); return acc; }, {}); const workerIds = Object.keys(workerData).sort((a, b) => { if (a === "N/A") return 1; if (b === "N/A") return -1; return parseInt(a, 10) - parseInt(b, 10); }); if (workerIds.length === 0) { return '<div class="no-data">Could not determine worker distribution from test data.</div>'; } const chartId = `workerDistChart-${Date.now()}-${Math.random() .toString(36) .substring(2, 7)}`; const renderFunctionName = `renderWorkerDistChart_${chartId.replace( /-/g, "_" )}`; const modalJsNamespace = `modal_funcs_${chartId.replace(/-/g, "_")}`; // The categories now just need the name for the axis labels const categories = workerIds.map((id) => `Worker ${id}`); // We pass the full data separately to the script const fullWorkerData = workerIds.map((id) => ({ id: id, name: `Worker ${id}`, tests: workerData[id].tests, })); const passedData = workerIds.map((id) => workerData[id].passed); const failedData = workerIds.map((id) => workerData[id].failed); const skippedData = workerIds.map((id) => workerData[id].skipped); const categoriesString = JSON.stringify(categories); const fullDataString = JSON.stringify(fullWorkerData); const seriesString = JSON.stringify([ { name: "Passed", data: passedData, color: "var(--success-color)" }, { name: "Failed", data: failedData, color: "var(--danger-color)" }, { name: "Skipped", data: skippedData, color: "var(--warning-color)" }, ]); // The HTML now includes the chart container, the modal, and styles for the modal return ` <style> .worker-modal-overlay { position: fixed; z-index: 1050; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.6); display: none; align-items: center; justify-content: center; } .worker-modal-content { background-color: #3d4043; color: var(--card-background-color); margin: auto; padding: 20px; border: 1px solid var(--border-color, #888); width: 80%; max-width: 700px; border-radius: 8px; position: relative; box-shadow: 0 5px 15px rgba(0,0,0,0.5); } .worker-modal-close { position: absolute; top: 10px; right: 20px; font-size: 28px; font-weight: bold; cursor: pointer; line-height: 1; } .worker-modal-close:hover, .worker-modal-close:focus { color: var(--text-color, #000); } #worker-modal-body-${chartId} ul { list-style-type: none; padding-left: 0; margin-top: 15px; max-height: 45vh; overflow-y: auto; } #worker-modal-body-${chartId} li { padding: 8px 5px; border-bottom: 1px solid var(--border-color, #eee); font-size: 0.9em; } #worker-modal-body-${chartId} li:last-child { border-bottom: none; } #worker-modal-body-${chartId} li > span { display: inline-block; width: 70px; font-weight: bold; text-align: right; margin-right: 10px; } </style> <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}" style="min-height: 350px;"> <div class="no-data">Loading Worker Distribution Chart...</div> </div> <div id="worker-modal-${chartId}" class="worker-modal-overlay"> <div class="worker-modal-content"> <span class="worker-modal-close">×</span> <h3 id="worker-modal-title-${chartId}" style="text-align: center; margin-top: 0; margin-bottom: 25px; font-size: 1.25em; font-weight: 600; color: #fff"></h3> <div id="worker-modal-body-${chartId}"></div> </div> </div> <script> // Namespace for modal functions to avoid global scope pollution window.${modalJsNamespace} = {}; window.${renderFunctionName} = function() { const chartContainer = document.getElementById('${chartId}'); if (!chartContainer) { console.error("Chart container ${chartId} not found."); return; } // --- Modal Setup --- const modal = document.getElementById('worker-modal-${chartId}'); const modalTitle = document.getElementById('worker-modal-title-${chartId}'); const modalBody = document.getElementById('worker-modal-body-${chartId}'); const closeModalBtn = modal.querySelector('.worker-modal-close'); window.${modalJsNamespace}.open = function(worker) { if (!worker) return; modalTitle.textContent = 'Test Details for ' + worker.name; let testListHtml = '<ul>'; if (worker.tests && worker.tests.length > 0) { worker.tests.forEach(test => { let color = 'inherit'; if (test.status === 'passed') color = 'var(--success-color)'; else if (test.status === 'failed') color = 'var(--danger-color)'; else if (test.status === 'skipped') color = 'var(--warning-color)'; const escapedName = test.name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); testListHtml += \`<li style="color: \${color};"><span style="color: \${color}">[\${test.status.toUpperCase()}]</span> \${escapedName}</li>\`; }); } else { testListHtml += '<li>No detailed test data available for this worker.</li>'; } testListHtml += '</ul>'; modalBody.innerHTML = testListHtml; modal.style.display = 'flex'; }; const closeModal = function() { modal.style.display = 'none'; }; closeModalBtn.onclick = closeModal; modal.onclick = function(event) { // Close if clicked on the dark overlay background if (event.target == modal) { closeModal(); } }; // --- Highcharts Setup --- if (typeof Highcharts !== 'undefined') { try { chartContainer.innerHTML = ''; const fullData = ${fullDataString}; const chartOptions = { chart: { type: 'bar', height: 350, backgroundColor: 'transparent' }, title: { text: null }, xAxis: { categories: ${categoriesString}, title: { text: 'Worker ID' }, labels: { style: { color: 'var(--text-color-secondary)' }} }, yAxis: { min: 0, title: { text: 'Number of Tests' }, labels: { style: { color: 'var(--text-color-secondary)' }}, stackLabels: { enabled: true, style: { fontWeight: 'bold', color: 'var(--text-color)' } } }, legend: { reversed: true, itemStyle: { fontSize: "12px", color: 'var(--text-color)' } }, plotOptions: { series: { stacking: 'normal', cursor: 'pointer', point: { events: { click: function () { // 'this.x' is the index of the category const workerData = fullData[this.x]; window.${modalJsNamespace}.open(workerData); } } } } }, tooltip: { shared: true, headerFormat: '<b>{point.key}</b> (Click for details)<br/>', pointFormat: '<span style="color:{series.color}">●</span> {series.name}: <b>{point.y}</b><br/>', footerFormat: 'Total: <b>{point.total}</b>' }, series: ${seriesString}, credits: { enabled: false } }; Highcharts.chart('${chartId}', chartOptions); } catch (e) { console.error("Error rendering chart ${chartId}:", e); chartContainer.innerHTML = '<div class="no-data">Error rendering worker distribution chart.</div>'; } } else { chartContainer.innerHTML = '<div class="no-data">Charting library not available for worker distribution.</div>'; } }; </script> `; } const infoTooltip = ` <span class="info-tooltip" style="display: inline-block; margin-left: 8px;"> <span class="info-icon" style="cursor: pointer; font-size: 1.25rem;" onclick="window.workerInfoPrompt()">ℹ️</span> </span> <script> window.workerInfoPrompt = function() { const message = 'Why is worker -1 special?\\n\\n' + 'Playwright assigns skipped t