@arghajit/playwright-pulse-report
Version:
A Playwright reporter and dashboard for visualizing test results.
1,295 lines (1,189 loc) • 138 kB
JavaScript
#!/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";
// 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
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>
`;
}
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 accentColorAltRGB = "255, 152, 0"; // Assuming var(--accent-color-alt) is Orange #FF9800
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
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;
}
.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;
}
.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;
}
.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;
}
.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">CI/Local Test</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 tests to worker -1 because:\\n' +
'1. They don\\'t require browser execution\\n' +
'2. This keeps real workers focused on actual tests\\n' +
'3. Maintains clean reporting\\n\\n' +
'This is an intentional optimization by Playwright.';
alert(message);
}
</script>
`;
function generateTestHistoryContent(trendData) {
if (
!trendData ||
!trendData.testRuns ||
Object.keys(trendData.testRuns).length === 0
) {
return '<div class="no-data">No historical test data available.</div>';
}
const allTestNamesAndPaths = new Map();
Object.values(trendData.testRuns).forEach((run) => {
if (Array.isArray(run)) {
run.forEach((test) => {
if (test && test.testName && !allTestNamesAndPaths.has(test.testName)) {
const parts = test.testName.split(" > ");
const title = parts[parts.length - 1];
allTestNamesAndPaths.set(test.testName, title);
}
});
}
});
if (allTestNamesAndPaths.size === 0) {
return '<div class="no-data">No historical test data found after processing.</div>';
}
const testHistory = Array.from(allTestNamesAndPaths.entries())
.map(([fullTestName, testTitle]) => {
const history = [];
(trendData.overall || []).forEach((overallRun,