@arghajit/dummy
Version:
A Playwright reporter and dashboard for visualizing test results.
1,288 lines (1,209 loc) • 113 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-static-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) =>
({ "&": "&", "<": "<", ">": ">", '"': '"', "'": "'" }[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) {
return (
str
// Convert leading spaces to and tabs to
.replace(/^(\s+)/gm, (match) =>
match.replace(/ /g, " ").replace(/\t/g, " ")
)
// Color and style replacements
.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;">') // Changed to apply bold
.replace(/<\/color>/g, "</span>")
.replace(/<\/intensity>/g, "</span>")
// Convert newlines to <br> after processing other replacements
.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;
// Decision: Are we going to display hours or minutes?
// This happens if the duration is inherently >= 1 minute OR
// if it's < 1 minute but ceiling the seconds makes it >= 1 minute.
if (
totalRawSeconds < SECONDS_PER_MINUTE &&
Math.ceil(totalRawSeconds) < SECONDS_PER_MINUTE
) {
// Strictly seconds-only display, use precision.
return `${totalRawSeconds.toFixed(validPrecision)}s`;
} else {
// Display will include minutes and/or hours, or seconds round up to a minute.
// Seconds part should be an integer (ceiling).
// Round the total milliseconds UP to the nearest full second.
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); // This will be an integer
const parts = [];
if (h > 0) {
parts.push(`${h}h`);
}
// Show minutes if:
// - hours are present (e.g., "1h 0m 5s")
// - OR minutes themselves are > 0 (e.g., "5m 10s")
// - OR the original duration was >= 1 minute (ensures "1m 0s" for 60000ms)
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 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, index) => {
const runKey = overallRun.runId
? `test run ${overallRun.runId}`
: `test run ${index + 1}`;
const testRunForThisOverallRun = trendData.testRuns[runKey]?.find(
(t) => t && t.testName === fullTestName
);
if (testRunForThisOverallRun) {
history.push({
runId: overallRun.runId || index + 1,
status: testRunForThisOverallRun.status || "unknown",
duration: testRunForThisOverallRun.duration || 0,
timestamp:
testRunForThisOverallRun.timestamp ||
overallRun.timestamp ||
new Date(),
});
}
});
return { fullTestName, testTitle, history };
})
.filter((item) => item.history.length > 0);
return `
<div class="test-history-container">
<div class="filters" style="border-color: black; border-style: groove;">
<input type="text" id="history-filter-name" placeholder="Search by test title..." style="border-color: black; border-style: outset;">
<select id="history-filter-status">
<option value="">All Statuses</option>
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="skipped">Skipped</option>
</select>
<button id="clear-history-filters" class="clear-filters-btn">Clear Filters</button>
</div>
<div class="test-history-grid">
${testHistory
.map((test) => {
const latestRun =
test.history.length > 0
? test.history[test.history.length - 1]
: { status: "unknown" };
return `
<div class="test-history-card" data-test-name="${sanitizeHTML(
test.testTitle.toLowerCase()
)}" data-latest-status="${latestRun.status}">
<div class="test-history-header">
<p title="${sanitizeHTML(test.testTitle)}">${capitalize(
sanitizeHTML(test.testTitle)
)}</p>
<span class="status-badge ${getStatusClass(latestRun.status)}">
${String(latestRun.status).toUpperCase()}
</span>
</div>
<div class="test-history-trend">
${generateTestHistoryChart(test.history)}
</div>
<details class="test-history-details-collapsible">
<summary>Show Run Details (${test.history.length})</summary>
<div class="test-history-details">
<table>
<thead><tr><th>Run</th><th>Status</th><th>Duration</th><th>Date</th></tr></thead>
<tbody>
${test.history
.slice()
.reverse()
.map(
(run) => `
<tr>
<td>${run.runId}</td>
<td><span class="status-badge-small ${getStatusClass(
run.status
)}">${String(run.status).toUpperCase()}</span></td>
<td>${formatDuration(run.duration)}</td>
<td>${formatDate(run.timestamp)}</td>
</tr>`
)
.join("")}
</tbody>
</table>
</div>
</details>
</div>`;
})
.join("")}
</div>
</div>
`;
}
function getStatusClass(status) {
switch (String(status).toLowerCase()) {
case "passed":
return "status-passed";
case "failed":
return "status-failed";
case "skipped":
return "status-skipped";
default:
return "status-unknown";
}
}
function getStatusIcon(status) {
switch (String(status).toLowerCase()) {
case "passed":
return "✅";
case "failed":
return "❌";
case "skipped":
return "⏭️";
default:
return "❓";
}
}
function getSuitesData(results) {
const suitesMap = new Map();
if (!results || results.length === 0) return [];
results.forEach((test) => {
const browser = test.browser || "unknown";
const suiteParts = test.name.split(" > ");
let suiteNameCandidate = "Default Suite";
if (suiteParts.length > 2) {
suiteNameCandidate = suiteParts[1];
} else if (suiteParts.length > 1) {
suiteNameCandidate = suiteParts[0]
.split(path.sep)
.pop()
.replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
} else {
suiteNameCandidate = test.name
.split(path.sep)
.pop()
.replace(/\.(spec|test)\.(ts|js|mjs|cjs)$/, "");
}
const suiteName = suiteNameCandidate;
const key = `${suiteName}|${browser}`;
if (!suitesMap.has(key)) {
suitesMap.set(key, {
id: test.id || key,
name: suiteName,
browser: browser,
passed: 0,
failed: 0,
skipped: 0,
count: 0,
statusOverall: "passed",
});
}
const suite = suitesMap.get(key);
suite.count++;
const currentStatus = String(test.status).toLowerCase();
if (currentStatus && suite[currentStatus] !== undefined) {
suite[currentStatus]++;
}
if (currentStatus === "failed") suite.statusOverall = "failed";
else if (currentStatus === "skipped" && suite.statusOverall !== "failed")
suite.statusOverall = "skipped";
});
return Array.from(suitesMap.values());
}
function generateSuitesWidget(suitesData) {
if (!suitesData || suitesData.length === 0) {
return `<div class="suites-widget"><div class="suites-header"><h2>Test Suites</h2></div><div class="no-data">No suite data available.</div></div>`;
}
return `
<div class="suites-widget">
<div class="suites-header">
<h2>Test Suites</h2>
<span class="summary-badge">${
suitesData.length
} suites • ${suitesData.reduce(
(sum, suite) => sum + suite.count,
0
)} tests</span>
</div>
<div class="suites-grid">
${suitesData
.map(
(suite) => `
<div class="suite-card status-${suite.statusOverall}">
<div class="suite-card-header">
<h3 class="suite-name" title="${sanitizeHTML(
suite.name
)} (${sanitizeHTML(suite.browser)})">${sanitizeHTML(suite.name)}</h3>
</div>
<div>🖥️ <span class="browser-tag">${sanitizeHTML(
suite.browser
)}</span></div>
<div class="suite-card-body">
<span class="test-count">${suite.count} test${
suite.count !== 1 ? "s" : ""
}</span>
<div class="suite-stats">
${
suite.passed > 0
? `<span class="stat-passed" title="Passed"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check-circle-fill" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg> ${suite.passed}</span>`
: ""
}
${
suite.failed > 0
? `<span class="stat-failed" title="Failed"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg> ${suite.failed}</span>`
: ""
}
${
suite.skipped > 0
? `<span class="stat-skipped" title="Skipped"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> ${suite.skipped}</span>`
: ""
}
</div>
</div>
</div>`
)
.join("")}
</div>
</div>`;
}
function generateHTML(reportData, trendData = null) {
const { run, results } = reportData;
const suitesData = getSuitesData(reportData.results || []);
const runSummary = run || {
totalTests: 0,
passed: 0,
failed: 0,
skipped: 0,
duration: 0,
timestamp: new Date().toISOString(),
};
const totalTestsOr1 = runSummary.totalTests || 1;
const passPercentage = Math.round((runSummary.passed / totalTestsOr1) * 100);
const