playwright-performance-reporter
Version:
Measure and publish performance metrics from browser dev-tools when running playwright
521 lines (489 loc) • 15.7 kB
JavaScript
import { TimelineDataPresenter, } from '../timeline-data-presenter/index.js';
/**
* Presenter that generates an HTML chart visualization using Chart.js
*/
export class ChartPresenter extends TimelineDataPresenter {
/**
* Generate the HTML with Chart.js visualization
*/
generate() {
const uniqueMetrics = this.getUniqueMetrics();
const testNames = this.getUniqueTestNames();
// Get available comparison metrics for the first test (default selection)
const defaultTest = testNames[0] ?? '';
// Serialize chart data for JavaScript consumption
const chartDataJson = JSON.stringify(this.timelineData.map(d => ({
name: d.name,
timestamp: d.timestamp,
labels: d.labels,
values: d.values,
})));
// Serialize metrics summary for JavaScript consumption
const metricsSummaryJson = JSON.stringify([...this.computeMetricsSummary(defaultTest).entries()].map(([key, value]) => ({
name: key,
...value,
})));
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Performance Report Chart</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
.control-panel {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 10px;
}
.control-panel label {
font-weight: 600;
}
.control-panel select {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 14px;
min-width: 200px;
}
.chart-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 20px;
}
#performanceChart {
max-height: 500px;
}
.metrics-comparison-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.metrics-comparison-section h2 {
margin: 0 0 15px 0;
font-size: 18px;
color: #333;
}
.metrics-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.metric-card {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
border: 1px solid #e9ecef;
}
.metric-card h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #666;
font-weight: 600;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.metric-label {
font-size: 12px;
color: #888;
margin-bottom: 10px;
}
.metric-stats {
border-top: 1px solid #dee2e6;
padding-top: 10px;
font-size: 12px;
}
.metric-min {
color: #28a745;
margin-bottom: 3px;
}
.metric-max {
color: #dc3545;
}
.metric-count {
font-size: 11px;
color: #adb5bd;
margin-top: 10px;
text-align: right;
}
</style>
</head>
<body>
<h1>Performance Report</h1>
${this.generateMetricsComparisonSection(defaultTest)}
<div class="control-panel">
<div class="control-group">
<label for="testSelect">Test:</label>
<select id="testSelect">
${testNames.map(n => `<option value="${this.escapeHtml(n)}">${this.escapeHtml(n)}</option>`).join('\n ')}
</select>
</div>
<div class="control-group">
<label for="metricSelect">Metric:</label>
<select id="metricSelect">
${uniqueMetrics.map(m => `<option value="${this.escapeHtml(m)}">${this.escapeHtml(m)}</option>`).join('\n ')}
</select>
</div>
</div>
<div class="chart-container">
<canvas id="performanceChart" width="900" height="500"></canvas>
</div>
<script>
const chartData = ${chartDataJson};
const metricsSummaryData = ${metricsSummaryJson};
let chart = null;
function formatMetricValue(name, value) {
if (name.includes('Heap') || name.includes('Size')) {
if (value > 1048576) {
return (value / 1048576).toFixed(2) + ' MB';
}
return (value / 1024).toFixed(2) + ' KB';
}
if (name.includes('Duration') || name.includes('Time')) {
return value.toFixed(2) + ' ms';
}
return value.toFixed(2);
}
function computeMetricsSummaryForTest(testName) {
const testPoints = chartData
.filter(p => p.name === testName)
.sort((a, b) => a.timestamp - b.timestamp);
if (testPoints.length === 0) {
return new Map();
}
const allComparisonMetrics = metricsSummaryData.map(summaryData => summaryData.name);
const summary = new Map();
for (const metric of allComparisonMetrics) {
const values = [];
for (const point of testPoints) {
const idx = point.labels.indexOf(metric);
if (idx !== -1) {
values.push(point.values[idx]);
}
}
if (values.length === 0) {
continue;
}
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((s, v) => s + v, 0) / values.length;
summary.set(metric, { min, max, avg, count: values.length });
}
return summary;
}
function renderMetricsComparison(metricsSummary) {
const grid = document.getElementById('metricsComparisonGrid');
if (!grid) {
return;
}
let html = '';
for (const [metric, stats] of metricsSummary.entries()) {
const formattedMin = formatMetricValue(metric, stats.min);
const formattedMax = formatMetricValue(metric, stats.max);
const formattedAvg = formatMetricValue(metric, stats.avg);
html += \`
<div class="metric-card">
<h3>\${metric}</h3>
<div class="metric-value">\${formattedAvg}</div>
<div class="metric-label">Average</div>
<div class="metric-stats">
<div class="metric-min">Min: \${formattedMin}</div>
<div class="metric-max">Max: \${formattedMax}</div>
</div>
<div class="metric-count">Data Points: \${stats.count}</div>
</div>
\`;
}
grid.innerHTML = html;
}
function updateMetricsComparison(testName, metricName) {
// The metrics comparison shows all available metrics for the test,
// but we could filter by the selected metric if needed
const summary = computeMetricsSummaryForTest(testName);
renderMetricsComparison(summary);
}
function getChartData(testName, metricName) {
// Filter data points for the selected test that have the selected metric
const filteredData = chartData
.filter(point => point.name === testName && point.labels.includes(metricName))
.sort((a, b) => a.timestamp - b.timestamp);
if (filteredData.length === 0) {
return { labels: [], datasets: [] };
}
// Extract the metric values
const labels = filteredData.map(p => new Date(p.timestamp).toLocaleString());
const values = filteredData.map(p => {
const metricIndex = p.labels.indexOf(metricName);
return metricIndex !== -1 ? p.values[metricIndex] : null;
});
return {
labels: labels,
datasets: [{
label: metricName,
data: values,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
tension: 0.3,
fill: false
}]
};
}
function createChart(testName, metricName) {
const data = getChartData(testName, metricName);
if (chart) {
chart.destroy();
}
const ctx = document.getElementById('performanceChart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top'
},
title: {
display: true,
text: testName + ' - ' + metricName
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Timestamp'
}
},
y: {
display: true,
title: {
display: true,
text: metricName
}
}
}
}
});
}
const testSelect = document.getElementById('testSelect');
const metricSelect = document.getElementById('metricSelect');
// Initialize
createChart(testSelect.value, metricSelect.value);
// Update when test selection changes
testSelect.addEventListener('change', function() {
createChart(this.value, metricSelect.value);
updateMetricsComparison(this.value, metricSelect.value);
});
// Update when metric selection changes
metricSelect.addEventListener('change', function() {
createChart(testSelect.value, this.value);
updateMetricsComparison(testSelect.value, this.value);
});
// Initial metrics comparison render
updateMetricsComparison(testSelect.value, metricSelect.value);
</script>
</body>
</html>`;
}
/**
* Escape HTML special characters to prevent XSS
*/
escapeHtml(text) {
return text
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll('\'', ''');
}
/**
* Extract metric value from a datapoint by metric name
*
* @param datapoint The chart datapoint to extract from
* @param metricName The name of the metric to extract
* @returns The metric value or undefined if not found
*/
extractMetricValue(datapoint, metricName) {
const index = datapoint.labels.indexOf(metricName);
if (index === -1) {
return undefined;
}
return datapoint.values[index];
}
/**
* Get available metrics for a specific test name
*
* @param testName The test name to filter metrics for
* @returns Array of metric names available for the test
*/
getAvailableMetricsForTest(testName) {
const availableMetrics = new Set();
for (const datapoint of this.timelineData) {
if (datapoint.name === testName) {
for (const metric of datapoint.labels) {
availableMetrics.add(metric);
}
}
}
return [...availableMetrics].sort();
}
/**
* Compute summary statistics for a specific test
*
* @param testName The test name to compute summary for
* @returns Map of metric names to their summary statistics
*/
computeMetricsSummary(testName) {
const testDatapoints = this.timelineData
.filter(d => d.name === testName)
.sort((a, b) => a.timestamp - b.timestamp);
if (testDatapoints.length === 0) {
return new Map();
}
const summary = new Map();
const availableMetrics = this.getAvailableMetricsForTest(testName);
for (const metric of availableMetrics) {
const values = [];
for (const datapoint of testDatapoints) {
const value = this.extractMetricValue(datapoint, metric);
if (value !== undefined) {
values.push(value);
}
}
if (values.length === 0) {
continue;
}
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((sum, value) => sum + value, 0) / values.length;
summary.set(metric, {
min,
max,
avg,
count: values.length,
});
}
return summary;
}
/**
* Format metric value with appropriate units
*
* @param name The metric name
* @param value The metric value
* @returns Formatted string with units
*/
formatMetricValue(name, value) {
if (name.includes('Heap') || name.includes('Size')) {
if (value > 1_048_576) {
return `${(value / 1_048_576).toFixed(2)} MB`;
}
return `${(value / 1024).toFixed(2)} KB`;
}
if (name.includes('Duration') || name.includes('Time')) {
return `${value.toFixed(2)} ms`;
}
return value.toFixed(2);
}
/**
* Generate HTML for metrics comparison section
*
* @param testName The test name to generate comparison for
* @returns HTML string for the metrics comparison grid
*/
generateMetricsComparisonSection(testName) {
const availableMetrics = this.getAvailableMetricsForTest(testName);
if (availableMetrics.length === 0) {
return '';
}
const summary = this.computeMetricsSummary(testName);
if (summary.size === 0) {
return '';
}
const metricCards = [];
for (const metric of availableMetrics) {
const stats = summary.get(metric);
if (!stats) {
continue;
}
const formattedMin = this.formatMetricValue(metric, stats.min);
const formattedMax = this.formatMetricValue(metric, stats.max);
const formattedAvg = this.formatMetricValue(metric, stats.avg);
metricCards.push(`
<div class="metric-card">
<h3>${this.escapeHtml(metric)}</h3>
<div class="metric-value">${formattedAvg}<</div>
<div class="metric-label">Average</div>
<div class="metric-stats">
<div class="metric-min">Min: ${formattedMin}</div>
<div class="metric-max">Max: ${formattedMax}</div>
</div>
<div class="metric-count">Data Points: ${stats.count}</div>
</div>
`);
}
return `
<div class="metrics-comparison-section">
<h2>Metrics Summary</h2>
<div id="metricsComparisonGrid" class="metrics-comparison-grid">
${metricCards.join('\n')}
</div>
</div>
`;
}
/**
* Extract all unique metric names from chart data
*/
getUniqueMetrics() {
const metricSet = new Set();
for (const data of this.timelineData) {
for (const label of data.labels) {
metricSet.add(label);
}
}
return [...metricSet].sort();
}
/**
* Get all unique test names from chart data
*/
getUniqueTestNames() {
const nameSet = new Set();
for (const data of this.timelineData) {
nameSet.add(data.name);
}
return [...nameSet];
}
}