@arghajit/playwright-pulse-report
Version:
A Playwright reporter and dashboard for visualizing test results.
1,314 lines (1,214 loc) • 152 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";
/**
* Dynamically imports the 'chalk' library for terminal string styling.
* This is necessary because chalk is an ESM-only module.
* If the import fails, a fallback object with plain console log functions is used.
*/
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,
};
}
/**
* @constant {string} DEFAULT_OUTPUT_DIR
* The default directory where the report will be generated.
*/
const DEFAULT_OUTPUT_DIR = "pulse-report";
/**
* @constant {string} DEFAULT_JSON_FILE
* The default name for the JSON file containing the test data.
*/
const DEFAULT_JSON_FILE = "playwright-pulse-report.json";
/**
* @constant {string} DEFAULT_HTML_FILE
* The default name for the generated HTML report file.
*/
const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
// Helper functions
/**
* Converts a string with ANSI escape codes to an HTML string with inline styles.
* @param {string} text The text with ANSI codes.
* @returns {string} The converted HTML string.
*/
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;
}
/**
* Sanitizes an HTML string by replacing special characters with their corresponding HTML entities.
* @param {string} str The HTML string to sanitize.
* @returns {string} The sanitized HTML string.
*/
function sanitizeHTML(str) {
if (str === null || str === undefined) return "";
return String(str).replace(
/[&<>"']/g,
(match) =>
({ "&": "&", "<": "<", ">": ">", '"': '"', "'": "'" }[match] || match)
);
}
/**
* Capitalizes the first letter of a string and converts the rest to lowercase.
* @param {string} str The string to capitalize.
* @returns {string} The capitalized string.
*/
function capitalize(str) {
if (!str) return "";
return str[0].toUpperCase() + str.slice(1).toLowerCase();
}
/**
* Formats a Playwright error object or message into an HTML string.
* @param {Error|string} error The error object or message string.
* @returns {string} The formatted HTML error string.
*/
function formatPlaywrightError(error) {
const commandOutput = ansiToHtml(error || error.message);
return convertPlaywrightErrorToHTML(commandOutput);
}
/**
* Converts a string containing Playwright-style error formatting to HTML.
* @param {string} str The error string.
* @returns {string} The HTML-formatted error string.
*/
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>");
}
/**
* Formats a duration in milliseconds into a human-readable string (e.g., '1h 2m 3s', '4.5s').
* @param {number} ms The duration in milliseconds.
* @param {object} [options={}] Formatting options.
* @param {number} [options.precision=1] The number of decimal places for seconds.
* @param {string} [options.invalidInputReturn="N/A"] The string to return for invalid input.
* @param {string|null} [options.defaultForNullUndefinedNegative=null] The value for null, undefined, or negative inputs.
* @returns {string} The formatted duration string.
*/
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(" ");
}
}
/**
* Generates HTML and JavaScript for a Highcharts line chart to display test result trends over multiple runs.
* @param {object} trendData The trend data.
* @param {Array<object>} trendData.overall An array of run objects with test statistics.
* @returns {string} The HTML string for the test trends chart.
*/
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>
`;
}
/**
* Generates HTML and JavaScript for a Highcharts area chart to display test duration trends.
* @param {object} trendData Data for duration trends.
* @param {Array<object>} trendData.overall Array of objects, each representing a test run with a duration.
* @returns {string} The HTML string for the duration trend chart.
*/
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>
`;
}
/**
* Formats a date string or Date object into a more readable format (e.g., "MM/DD/YY HH:MM").
* @param {string|Date} dateStrOrDate The date string or Date object to format.
* @returns {string} The formatted date string, or "N/A" for invalid dates.
*/
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";
}
}
/**
* Generates a small area chart showing the duration history of a single test across multiple runs.
* The status of each run is indicated by the color of the marker.
* @param {Array<object>} history An array of run objects, each with status and duration.
* @returns {string} The HTML string for the test history chart.
*/
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>
`;
}
/**
* Generates a Highcharts pie chart to visualize the distribution of test statuses.
* @param {Array<object>} data The data for the pie chart, with each object having a 'label' and 'value'.
* @param {number} [chartWidth=300] The width of the chart.
* @param {number} [chartHeight=300] The height of the chart.
* @returns {string} The HTML string for the pie chart.
*/
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>
`;
}
/**
* Generates an HTML dashboard to display environment details.
* @param {object} environment The environment information.
* @param {number} [dashboardHeight=600] The height of the dashboard.
* @returns {string} The HTML string for the environment dashboard.
*/
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: #4a9eff;
--primary-light-color: #1a2332;
--secondary-color: #9ca3af;
--success-color: #34d399;
--success-light-color: #1a2e23;
--warning-color: #fbbf24;
--warning-light-color: #2d2a1a;
--danger-color: #f87171;
--background-color: #1f2937;
--card-background-color: #374151;
--text-color: #f9fafb;
--text-color-secondary: #d1d5db;
--border-color: #4b5563;
--border-light-color: #374151;
--icon-color: #d1d5db;
--chip-background: #4b5563;
--chip-text: #f9fafb;
--shadow-color: rgba(0, 0, 0, 0.3);
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(255,255,255,0.2);
}
.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>
`;
}
/**
* Generates a Highcharts bar chart to visualize the distribution of test results across different workers.
* @param {Array<object>} results The test results data.
* @returns {string} The HTML string for the worker distribution chart and its associated modal.
*/
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: 10000; left: 0; top: 0; width: 100%; height: 100%;
overflow: auto; background-color: rgba(0,0,0,0.8);
display: none; align-items: center; justify-content: center;
}
.worker-modal-content {
background-color: #1f2937;
color: #f9fafb;
margin: auto; padding: 20px; border: 1px solid #4b5563;
width: 80%; max-width: 700px; border-radius: 8px;
position: relative; box-shadow: 0 5px 15px rgba(0,0,0,0.7);
}
.worker-modal-close {
position: absolute; top: 10px; right: 20px;
font-size: 28px; font-weight: bold; cursor: pointer;
line-height: 1; color: #d1d5db;
}
.worker-modal-close:hover, .worker-modal-close:focus {
color: #f9fafb;
}
#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 #4b5563;
font-size: 0.9em; color: #f9fafb;
}
#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;
color: #d1d5db;
}
</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');
if (modal && modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
// Lightweight HTML escaper for client-side use
function __escHtml(s){return String(s==null?'':s).replace(/[&<>\"]/g,function(ch){return ch==='&'?'&':ch==='<'?'<':ch==='>'?'>':'"';});}
window.${modalJsNamespace}.open = function(worker) {
if (!worker) return;
try {
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 safeName = __escHtml(test.name);
testListHtml += '<li style="color: ' + color + ';"><span style="color: ' + color + '">[' + String(test.status).toUpperCase() + ']</span> ' + safeName + '</li>';
});
} else {
testListHtml += '<li>No detailed test data available for this worker.</li>';
}
testListHtml += '</ul>';
modalBody.innerHTML = testListHtml;
if (typeof openModal === 'function') openModal(); else modal.style.display = 'flex';
} catch (err) {
console.error('Failed to open worker modal:', err);
}
};
const closeModal = function() {
modal.style.display = 'none';
try { document.body.style.overflow = ''; } catch (_) {}
};
const openModal = function() {
modal.style.display = 'flex';
try { document.body.style.overflow = 'hidden'; } catch (_) {}
};
if (closeModalBtn) closeModalBtn.onclick = closeModal;
modal.addEventListener('click', function(event) {
if (event.target === modal) {
closeModal();
}
});
document.addEventListener('keydown', function escHandler(e) {
if (modal.style.display === 'flex' && (e.key === 'Escape' || e.key === 'Esc')) {
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)