#!/usr/bin/env bun /** * Performance Test Comparison Tool * * Compares two performance test results and shows the differences */ import { readFileSync } from "fs"; interface PerformanceMetrics { fcp: number; lcp: number; cls: number; fid: number; ttfb: number; domContentLoaded: number; loadComplete: number; totalRequests: number; totalBytes: number; jsBytes: number; cssBytes: number; imageBytes: number; fontBytes: number; jsRequests: number; cssRequests: number; imageRequests: number; jsExecutionTime: number; taskDuration: number; layoutDuration: number; paintDuration: number; } interface TestResult { page: string; url: string; median: PerformanceMetrics; } interface TestOutput { timestamp: string; baseUrl: string; runsPerPage: number; results: TestResult[]; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes.toFixed(0)}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; } function formatTime(ms: number): string { if (ms < 1000) return `${ms.toFixed(0)}ms`; return `${(ms / 1000).toFixed(2)}s`; } function formatDiff(value: number, unit: "ms" | "bytes" | "count"): string { const sign = value > 0 ? "+" : ""; if (unit === "ms") { return value === 0 ? "→" : `${sign}${formatTime(Math.abs(value))}`; } else if (unit === "bytes") { return value === 0 ? "→" : `${sign}${formatBytes(Math.abs(value))}`; } else { return value === 0 ? "→" : `${sign}${value.toFixed(0)}`; } } function getImpact(value: number, threshold: number = 5): string { const percentChange = Math.abs(value); if (percentChange < threshold) return ""; if (value < 0) return " šŸŽ‰"; // Improvement if (value > 0) return " āš ļø"; // Regression return ""; } function calculatePercentChange(before: number, after: number): number { if (before === 0) return after === 0 ? 0 : 100; return ((after - before) / before) * 100; } function compareResults(baseline: TestOutput, optimized: TestOutput) { console.log("\n"); console.log( "═══════════════════════════════════════════════════════════════════" ); console.log( " PERFORMANCE COMPARISON REPORT " ); console.log( "═══════════════════════════════════════════════════════════════════" ); console.log(`Baseline: ${baseline.timestamp}`); console.log(`Optimized: ${optimized.timestamp}`); console.log( "───────────────────────────────────────────────────────────────────\n" ); // Compare each page for (const baseResult of baseline.results) { const optResult = optimized.results.find((r) => r.page === baseResult.page); if (!optResult) continue; const base = baseResult.median; const opt = optResult.median; console.log(`\nšŸ“„ ${baseResult.page}`); console.log("─".repeat(70)); // Core Web Vitals console.log("\n Core Web Vitals:"); const fcpDiff = opt.fcp - base.fcp; const fcpPercent = calculatePercentChange(base.fcp, opt.fcp); console.log( ` FCP: ${formatTime(base.fcp)} → ${formatTime(opt.fcp)} (${formatDiff(fcpDiff, "ms")}, ${fcpPercent.toFixed(1)}%)${getImpact(fcpPercent)}` ); const clsDiff = opt.cls - base.cls; console.log( ` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})` ); // Loading Metrics console.log("\n Loading Metrics:"); const ttfbDiff = opt.ttfb - base.ttfb; const ttfbPercent = calculatePercentChange(base.ttfb, opt.ttfb); console.log( ` TTFB: ${formatTime(base.ttfb)} → ${formatTime(opt.ttfb)} (${formatDiff(ttfbDiff, "ms")}, ${ttfbPercent.toFixed(1)}%)${getImpact(ttfbPercent)}` ); const dclDiff = opt.domContentLoaded - base.domContentLoaded; const dclPercent = calculatePercentChange( base.domContentLoaded, opt.domContentLoaded ); console.log( ` DCL: ${formatTime(base.domContentLoaded)} → ${formatTime(opt.domContentLoaded)} (${formatDiff(dclDiff, "ms")}, ${dclPercent.toFixed(1)}%)${getImpact(dclPercent)}` ); const loadDiff = opt.loadComplete - base.loadComplete; const loadPercent = calculatePercentChange( base.loadComplete, opt.loadComplete ); console.log( ` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}` ); // Resource Loading console.log("\n Resources:"); const reqDiff = opt.totalRequests - base.totalRequests; const reqPercent = calculatePercentChange( base.totalRequests, opt.totalRequests ); console.log( ` Requests: ${base.totalRequests} → ${opt.totalRequests} (${formatDiff(reqDiff, "count")}, ${reqPercent.toFixed(1)}%)${getImpact(reqPercent, 10)}` ); const bytesDiff = opt.totalBytes - base.totalBytes; const bytesPercent = calculatePercentChange( base.totalBytes, opt.totalBytes ); console.log( ` Total Size: ${formatBytes(base.totalBytes)} → ${formatBytes(opt.totalBytes)} (${formatDiff(bytesDiff, "bytes")}, ${bytesPercent.toFixed(1)}%)${getImpact(bytesPercent, 10)}` ); const jsDiff = opt.jsBytes - base.jsBytes; const jsPercent = calculatePercentChange(base.jsBytes, opt.jsBytes); console.log( ` JS Size: ${formatBytes(base.jsBytes)} → ${formatBytes(opt.jsBytes)} (${formatDiff(jsDiff, "bytes")}, ${jsPercent.toFixed(1)}%)${getImpact(jsPercent, 10)}` ); const jsReqDiff = opt.jsRequests - base.jsRequests; const jsReqPercent = calculatePercentChange( base.jsRequests, opt.jsRequests ); console.log( ` JS Requests: ${base.jsRequests} → ${opt.jsRequests} (${formatDiff(jsReqDiff, "count")}, ${jsReqPercent.toFixed(1)}%)${getImpact(jsReqPercent, 10)}` ); } // Overall Summary console.log( "\n\n═══════════════════════════════════════════════════════════════════" ); console.log( " OVERALL SUMMARY " ); console.log( "═══════════════════════════════════════════════════════════════════\n" ); const baseAvg = { fcp: baseline.results.reduce((sum, r) => sum + r.median.fcp, 0) / baseline.results.length, ttfb: baseline.results.reduce((sum, r) => sum + r.median.ttfb, 0) / baseline.results.length, dcl: baseline.results.reduce((sum, r) => sum + r.median.domContentLoaded, 0) / baseline.results.length, load: baseline.results.reduce((sum, r) => sum + r.median.loadComplete, 0) / baseline.results.length, requests: baseline.results.reduce((sum, r) => sum + r.median.totalRequests, 0) / baseline.results.length, bytes: baseline.results.reduce((sum, r) => sum + r.median.totalBytes, 0) / baseline.results.length, jsBytes: baseline.results.reduce((sum, r) => sum + r.median.jsBytes, 0) / baseline.results.length, jsRequests: baseline.results.reduce((sum, r) => sum + r.median.jsRequests, 0) / baseline.results.length }; const optAvg = { fcp: optimized.results.reduce((sum, r) => sum + r.median.fcp, 0) / optimized.results.length, ttfb: optimized.results.reduce((sum, r) => sum + r.median.ttfb, 0) / optimized.results.length, dcl: optimized.results.reduce((sum, r) => sum + r.median.domContentLoaded, 0) / optimized.results.length, load: optimized.results.reduce((sum, r) => sum + r.median.loadComplete, 0) / optimized.results.length, requests: optimized.results.reduce((sum, r) => sum + r.median.totalRequests, 0) / optimized.results.length, bytes: optimized.results.reduce((sum, r) => sum + r.median.totalBytes, 0) / optimized.results.length, jsBytes: optimized.results.reduce((sum, r) => sum + r.median.jsBytes, 0) / optimized.results.length, jsRequests: optimized.results.reduce((sum, r) => sum + r.median.jsRequests, 0) / optimized.results.length }; console.log(" Average Across All Pages:\n"); const metrics = [ { name: "FCP", base: baseAvg.fcp, opt: optAvg.fcp, unit: "ms" as const }, { name: "TTFB", base: baseAvg.ttfb, opt: optAvg.ttfb, unit: "ms" as const }, { name: "DOM Content Loaded", base: baseAvg.dcl, opt: optAvg.dcl, unit: "ms" as const }, { name: "Load Complete", base: baseAvg.load, opt: optAvg.load, unit: "ms" as const }, { name: "Total Requests", base: baseAvg.requests, opt: optAvg.requests, unit: "count" as const }, { name: "Total Size", base: baseAvg.bytes, opt: optAvg.bytes, unit: "bytes" as const }, { name: "JS Size", base: baseAvg.jsBytes, opt: optAvg.jsBytes, unit: "bytes" as const }, { name: "JS Requests", base: baseAvg.jsRequests, opt: optAvg.jsRequests, unit: "count" as const } ]; metrics.forEach((metric) => { const diff = metric.opt - metric.base; const percent = calculatePercentChange(metric.base, metric.opt); const baseStr = metric.unit === "bytes" ? formatBytes(metric.base) : metric.unit === "ms" ? formatTime(metric.base) : metric.base.toFixed(1); const optStr = metric.unit === "bytes" ? formatBytes(metric.opt) : metric.unit === "ms" ? formatTime(metric.opt) : metric.opt.toFixed(1); console.log( ` ${metric.name.padEnd(20)} ${baseStr.padEnd(10)} → ${optStr.padEnd(10)} (${formatDiff(diff, metric.unit).padEnd(12)}, ${percent.toFixed(1).padStart(6)}%)${getImpact(percent, 5)}` ); }); console.log("\n Key Findings:\n"); let improvements = 0; let regressions = 0; metrics.forEach((metric) => { const percent = calculatePercentChange(metric.base, metric.opt); if (Math.abs(percent) >= 5) { if (percent < 0) improvements++; else regressions++; } }); if (improvements > 0) { console.log( ` āœ… ${improvements} significant improvement${improvements === 1 ? "" : "s"}` ); } if (regressions > 0) { console.log( ` āš ļø ${regressions} significant regression${regressions === 1 ? "" : "s"}` ); } // Specific findings const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests); if (reqPercent < -5) { console.log( ` šŸŽÆ Reduced HTTP requests by ${Math.abs(reqPercent).toFixed(1)}%` ); } const jsPercent = calculatePercentChange(baseAvg.jsBytes, optAvg.jsBytes); if (jsPercent < -5) { console.log( ` šŸ“¦ Reduced JS bundle size by ${Math.abs(jsPercent).toFixed(1)}%` ); } const loadPercent = calculatePercentChange(baseAvg.load, optAvg.load); if (Math.abs(loadPercent) < 5) { console.log( ` āš–ļø Load time remained stable (${Math.abs(loadPercent).toFixed(1)}% change)` ); } console.log("\n"); } function main() { const args = process.argv.slice(2); if (args.length !== 2) { console.error("Usage: bun run compare.ts "); process.exit(1); } const [baselinePath, optimizedPath] = args; try { const baseline: TestOutput = JSON.parse( readFileSync(baselinePath, "utf-8") ); const optimized: TestOutput = JSON.parse( readFileSync(optimizedPath, "utf-8") ); compareResults(baseline, optimized); } catch (error) { console.error("Error reading or parsing files:", error); process.exit(1); } } main();